Compare commits
947 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32b6ec166a | |||
| 3b2eb38d9a | |||
| cfa28298bc | |||
| 6c077f5289 | |||
| 3319749c0d | |||
| 702e1826d3 | |||
| f3a1cd3aef | |||
| d9dedf6f19 | |||
| f10085f552 | |||
| eb7f0887bf | |||
| 34a9132d60 | |||
| 24d3fb000a | |||
| b7b6adaff7 | |||
| 85ed72802f | |||
| f54f8c83e7 | |||
| 124bfdf418 | |||
| 01507d9995 | |||
| 070c82bc44 | |||
| 17df15293f | |||
| 6551076613 | |||
| 1b1b100e63 | |||
| e70014bb28 | |||
| 082cc84a71 | |||
| 2ba7f0d989 | |||
| 9a7381b35f | |||
| 613ecfff44 | |||
| f638548a02 | |||
| 224bab68bf | |||
| 6aca18bd76 | |||
| 3c58e2fbba | |||
| aaaebde05e | |||
| 8ff9f70bd7 | |||
| 523ca862b9 | |||
| 78b8d1e9f3 | |||
| 83c698b36d | |||
| 27fe37f221 | |||
| 3722106daf | |||
| 3ea9ef1ef2 | |||
| fcd27b48b2 | |||
| 6ff37a17ec | |||
| f2c6e9e32e | |||
| dbf1b88a3d | |||
| 3aab9eb13b | |||
| 3f9f8f7b3b | |||
| f7520ba40c | |||
| 7b13f12817 | |||
| 57e19d75e2 | |||
| 4330a223c6 | |||
| 69387c32ad | |||
| a102ec4ee8 | |||
| 2a094437dd | |||
| 4142907376 | |||
| e63dc9a93b | |||
| 1fe19e912e | |||
| 56ad48446e | |||
| a18aa26985 | |||
| 5443cc014a | |||
| 7d77e1c1f2 | |||
| ca82cd9752 | |||
| dbd86bb861 | |||
| 0af56b4981 | |||
| f1be5365bb | |||
| c356862ac1 | |||
| fc77322f59 | |||
| 032e944d49 | |||
| 0da47da8d8 | |||
| ebfe843299 | |||
| 827d2362b7 | |||
| 4fa1eb4088 | |||
| 885b59fd52 | |||
| ff5d9f72aa | |||
| 030bb91789 | |||
| 34788b2808 | |||
| abc4123c52 | |||
| 26f8c0842e | |||
| d430d83ba8 | |||
| a52f189fb1 | |||
| dc1045b69e | |||
| 3a89dbf4ab | |||
| d69b4eca1e | |||
| 568bba82f0 | |||
| b52e6c88ec | |||
| 720595091e | |||
| fe929c8e58 | |||
| 9ecec1eb3c | |||
| 79532f6f14 | |||
| b400ba385e | |||
| 9915007f30 | |||
| 0168a55596 | |||
| 229eff49fb | |||
| 0e3b472f78 | |||
| 5f29b30d34 | |||
| 9480363362 | |||
| 5dd80edde4 | |||
| 2243cf2017 | |||
| d7791c8543 | |||
| 30822c1ba5 | |||
| d250f4dc60 | |||
| bc27492206 | |||
| 2b63203a5b | |||
| dc9a26f57b | |||
| 6f2d7464ba | |||
| 6996c101b4 | |||
| 99dc773c7a | |||
| 6ec3f9455a | |||
| 6589a568e2 | |||
| 55da48e28c | |||
| 081cca48fb | |||
| 6b6a93725c | |||
| c280a52d33 | |||
| b659439f0b | |||
| 6453b3c45c | |||
| 978a879c43 | |||
| 833ef3630b | |||
| 024b8c93bc | |||
| d32c4f86a7 | |||
| cafc4450b2 | |||
| 7d69b53958 | |||
| 4d3e883e49 | |||
| 8f9a687872 | |||
| 08d509d831 | |||
| b06dec8449 | |||
| 28c93b934b | |||
| 394a57a26d | |||
| 3856b57a6f | |||
| 9a3a076890 | |||
| 314dfcddcf | |||
| 1a85bda997 | |||
| e8b30d5a88 | |||
| 96bd1a7799 | |||
| cb0a1f13bc | |||
| e92fdeef47 | |||
| 62bae62386 | |||
| 2636d79b86 | |||
| c9c1ef91fd | |||
| ce7bba3e09 | |||
| bfd53f39bd | |||
| 554fee037c | |||
| 67b2853ef0 | |||
| 0e29e13d03 | |||
| 35f2a238e9 | |||
| fcb34ab6ee | |||
| 3fbf682785 | |||
| 16086a6d3f | |||
| 18b6aae381 | |||
| 642c353164 | |||
| 42f64e5e88 | |||
| 6af748b2cc | |||
| 4fe97b69c7 | |||
| 04d46272dd | |||
| 1c0290dc7a | |||
| 38588402e3 | |||
| dfe3b8888d | |||
| e8f4022f1e | |||
| e37bb32c82 | |||
| 1f72c82acb | |||
| e6876926a4 | |||
| 2b8a43ab13 | |||
| 8737466368 | |||
| 83916fa43e | |||
| 1fe5a12a45 | |||
| fa7f3115ed | |||
| 4dc6143440 | |||
| f1503aa56c | |||
| 67f344b755 | |||
| f1bcc217a9 | |||
| 458460515d | |||
| 3a78095574 | |||
| d6bbfa1af1 | |||
| 4e1b778f31 | |||
| 5f4496036c | |||
| d4079940b4 | |||
| 803ad116fb | |||
| 27701eda49 | |||
| 71c831b02d | |||
| 0d72a0e009 | |||
| 69a4502f90 | |||
| daaa7f4e63 | |||
| d1579e9b0d | |||
| 5890fff240 | |||
| d6f6307050 | |||
| 6bf9c31860 | |||
| a2e628f3f8 | |||
| 8f5416ff31 | |||
| db86f18133 | |||
| 419e4e656e | |||
| eed4327d26 | |||
| 5c6eaf2602 | |||
| 71169ed740 | |||
| dca8d93aa8 | |||
| 6cb152f602 | |||
| ddefda3afa | |||
| 7f15f45beb | |||
| 46f887efec | |||
| 90afecd766 | |||
| 388343c3ee | |||
| c8df37e89e | |||
| cc85d5c343 | |||
| f8437cdb8f | |||
| c99a210905 | |||
| e33673c9e9 | |||
| 79283e93cb | |||
| a60e85a3a4 | |||
| 80910ed38d | |||
| 825a338474 | |||
| 19b6e94824 | |||
| 0b581011c5 | |||
| 6b7669bb75 | |||
| 67bcc56c6d | |||
| 7d8cfa3c6a | |||
| f659af29da | |||
| 94b202f7b6 | |||
| ca86fdafab | |||
| 370dbb1a10 | |||
| f77543cd9b | |||
| 7104e0d725 | |||
| 230a67cac0 | |||
| a695f38974 | |||
| 151c09f098 | |||
| 8200f5690d | |||
| 77a8cf2704 | |||
| fe424961e1 | |||
| b668cb78ff | |||
| 48278419b0 | |||
| 632da03667 | |||
| d36b73fc1b | |||
| 292ed35555 | |||
| 02d0ad496f | |||
| eb2fc7af40 | |||
| 6550deedbb | |||
| 80acd9b9eb | |||
| b961636f02 | |||
| f4df0714b5 | |||
| 91dd7b7049 | |||
| 121bef7d2d | |||
| 3a9eabf50b | |||
| c8198b4091 | |||
| e4538e4a51 | |||
| b47f3ef397 | |||
| 6e096c0ac3 | |||
| ee3b4686bf | |||
| 759b77eafe | |||
| de15ec666f | |||
| 3de86f15af | |||
| 1b9dff719c | |||
| abcf4e3d4a | |||
| 9823abe686 | |||
| 723e08f69b | |||
| daf0a0891e | |||
| 49dc68f77f | |||
| f81a1c36cc | |||
| 9a11a771dd | |||
| b56a4b8b49 | |||
| 1aa963992b | |||
| 1b601324d0 | |||
| 1aea723ef0 | |||
| 1e828a10b9 | |||
| 970423f873 | |||
| 2d7493fd1e | |||
| fa7eb1c4b1 | |||
| 4916af3697 | |||
| 8484bf1cb9 | |||
| 005afb3c73 | |||
| 84b0d004b9 | |||
| aa41bf8d97 | |||
| fba9a125bf | |||
| 27312bd146 | |||
| 8f0c267ab8 | |||
| a15d6a6b42 | |||
| 8f9061b250 | |||
| ec57499e08 | |||
| 381598c5b6 | |||
| 452d020da5 | |||
| b5f875c2e5 | |||
| a31daeda96 | |||
| 437f52f53a | |||
| 33f0f7ecf0 | |||
| 6777e79e70 | |||
| 16d1e6181b | |||
| a6c8db6c2c | |||
| 24aa0fecbe | |||
| 1a776b1990 | |||
| 27df265c81 | |||
| 84c0372719 | |||
| 3879e57c4c | |||
| dcc3dcdaba | |||
| d166635c7b | |||
| 33d484b7d1 | |||
| 26bff28e4d | |||
| 56eddff8d6 | |||
| 37b9133eb6 | |||
| 4a64967b1f | |||
| 23152b1264 | |||
| 00415aac79 | |||
| cbe602655c | |||
| 236d8b7030 | |||
| 392e3c7fe3 | |||
| 57f55e6856 | |||
| de54b27013 | |||
| c11338039f | |||
| e712669d32 | |||
| 3768ae33b7 | |||
| fdc39f0041 | |||
| 7f3b0b03a6 | |||
| 4a6a39dd4c | |||
| 6a8486a076 | |||
| 08a8a3043f | |||
| 7af290b6e1 | |||
| a896f9a28f | |||
| ea003483c4 | |||
| 5b73317e30 | |||
| 1af64b9985 | |||
| af784cf79b | |||
| a2b2131beb | |||
| 2433ce8d24 | |||
| 8b861750e5 | |||
| 99fcd3c669 | |||
| 0ddd8df272 | |||
| a96e508ffb | |||
| 1f21d12d2b | |||
| dd782ac4b2 | |||
| 51594e00b8 | |||
| 6c85f5f8c3 | |||
| d0432de981 | |||
| 2cbc94e51d | |||
| 3ea2aa1f74 | |||
| 1076b516d6 | |||
| 4e87d25851 | |||
| dadd3c7292 | |||
| 9f8abe35f9 | |||
| 0f869a7414 | |||
| aede16c85c | |||
| 61a82e6394 | |||
| 5a92925d6a | |||
| fe697c918f | |||
| bc57a285ce | |||
| 85d8943b64 | |||
| aa6c32968b | |||
| ad1808fb4e | |||
| 576610e4c3 | |||
| ace2266f14 | |||
| 41cedfa6ec | |||
| d46fab33b3 | |||
| 585dc45595 | |||
| c3c9354a00 | |||
| bdc8d08e65 | |||
| 9c792d3272 | |||
| 23bc4daf9f | |||
| fd85ca2004 | |||
| aadf88add1 | |||
| f14ce61ee3 | |||
| 539daf5789 | |||
| e8ea2a8ec1 | |||
| 9ed3b3a9df | |||
| 12487553de | |||
| 9c1a618b4a | |||
| ac0e784417 | |||
| 48cab6b203 | |||
| e1c0472069 | |||
| 2c498ce707 | |||
| bc483edb29 | |||
| 9762f4c412 | |||
| 5bfce88fc5 | |||
| 94ef66994d | |||
| 257c29daca | |||
| 173483eb84 | |||
| 06099b2663 | |||
| 33c1f0a71c | |||
| a3d78f1d80 | |||
| c573d213f8 | |||
| c72707aef9 | |||
| 313ef06c86 | |||
| 6b79340c15 | |||
| d9a5b29372 | |||
| d2b0e093fc | |||
| 945e563912 | |||
| a7efa379eb | |||
| d04df4ebe5 | |||
| 2a2c84ef3a | |||
| bc97db893a | |||
| f216834df7 | |||
| be25a7d594 | |||
| 10f43e8024 | |||
| bbb3e8d071 | |||
| 4c3af35156 | |||
| 8656228014 | |||
| 03f9ea8435 | |||
| 9cf27d8fb1 | |||
| d1b24ea6af | |||
| b07ffbde29 | |||
| 1673236940 | |||
| 06861a2d17 | |||
| ef7ac62f97 | |||
| 245a9f2751 | |||
| 1d38f158b5 | |||
| 62a526854d | |||
| 3dda940c92 | |||
| ab77c4720d | |||
| c8f1f9325e | |||
| 658940d3fb | |||
| 51b4ca401e | |||
| 10e4ca4ef3 | |||
| 2bcc2bdfe5 | |||
| 6462b580bb | |||
| b83d91c944 | |||
| 07f842bc9e | |||
| 3913e845fa | |||
| 09f0913974 | |||
| aa9ca35115 | |||
| 010dfdf834 | |||
| 150fac9c09 | |||
| ec3aef13d8 | |||
| 2b56005bd2 | |||
| 9bc893b6ad | |||
| 3feb92e788 | |||
| 1265952814 | |||
| f5ad5d97db | |||
| 5ac0939731 | |||
| b653694860 | |||
| 49051a5095 | |||
| 7734de6465 | |||
| edac646434 | |||
| 51c7665fdc | |||
| 37545821fc | |||
| 8a1ed0f146 | |||
| 7a0228fb81 | |||
| 3aecf9e031 | |||
| c2d4d221af | |||
| 53f89fbe22 | |||
| eb5f7ef7af | |||
| e2fc76d21d | |||
| 1754103175 | |||
| dacd00708f | |||
| a73129243c | |||
| 54a6aa9081 | |||
| 0fbb53c606 | |||
| eb2e79977d | |||
| b70a47f5e5 | |||
| 9d5ff72548 | |||
| 6b972b56a5 | |||
| a80d30baf7 | |||
| b9280e9a8e | |||
| c6640d201c | |||
| 795fdc3605 | |||
| 05311da33d | |||
| 2e442cb1d1 | |||
| fe322590cc | |||
| 6cf9b25c04 | |||
| 417babb3d4 | |||
| bdaaa6f0c7 | |||
| 7e92dd7fe4 | |||
| 50601e24ed | |||
| bfc3116661 | |||
| 264b6e54f2 | |||
| 1ed7ecc82f | |||
| 19b8032d06 | |||
| 1e254ea8f4 | |||
| babfc99c35 | |||
| 5c802555a2 | |||
| fbc41c9a4e | |||
| 1eca461cb1 | |||
| ebd327c7a6 | |||
| 602febe876 | |||
| 84fcd3ae6a | |||
| 84296c6e1c | |||
| 6012e0ea8c | |||
| 9c76defad0 | |||
| ffd6fab35c | |||
| 296f97f7ca | |||
| 9cbef34f29 | |||
| 7a65136d29 | |||
| acaebea846 | |||
| ce850ac12f | |||
| a93422d3ed | |||
| b2e605838e | |||
| 2e14002442 | |||
| c743949df5 | |||
| f207a3f6d1 | |||
| d6211605a1 | |||
| b16676b54a | |||
| dc9bfe5189 | |||
| 80620ed4c6 | |||
| 76bd0ab696 | |||
| e0914df58a | |||
| 20039a422e | |||
| 22b9c9ca68 | |||
| 0c546e35ec | |||
| b70370ac09 | |||
| aa10bb7dc5 | |||
| c6100a9be1 | |||
| 529a2f7bf8 | |||
| 9ec7e916c5 | |||
| 982b36cf98 | |||
| a73eab5e92 | |||
| a8479ccb5f | |||
| f55e4e0e01 | |||
| d08c32ce04 | |||
| 1d599c5e60 | |||
| e888ae59e4 | |||
| 951d544894 | |||
| 49898b34e1 | |||
| 3854a6a42e | |||
| d4da5bc281 | |||
| 04954f5242 | |||
| 9fc5496526 | |||
| e363d24b1c | |||
| 801f4027a2 | |||
| c0dc344f76 | |||
| b5b3d81f00 | |||
| 8dd8dbc1d1 | |||
| 8f31aa59a8 | |||
| 5b581b6c0f | |||
| 297ac64fde | |||
| d4490f0e17 | |||
| d04e7a3231 | |||
| 5b456aba27 | |||
| 0c065dcc1f | |||
| 531f73329d | |||
| d6ba72032d | |||
| bfdc7a2609 | |||
| 031abf03da | |||
| 6aac8e6be6 | |||
| 8ff93d21c3 | |||
| 6df3d0bc44 | |||
| 0b18e8fdb4 | |||
| 19d8ae0f78 | |||
| d7ffb5dddc | |||
| 2859b73dfe | |||
| 6f9021a5e6 | |||
| 3bfeaefdbd | |||
| db1eace975 | |||
| cab0fa176e | |||
| 2f9ae107a2 | |||
| 18c93abcb3 | |||
| bd64dfb661 | |||
| 82619063ee | |||
| 5dbf18d66e | |||
| 6a34ff2728 | |||
| f7c7487756 | |||
| f966cb4ca0 | |||
| 549563a3d2 | |||
| c5f2a3f8fe | |||
| 81a3bbd5e8 | |||
| 1509a2a799 | |||
| fc547b734f | |||
| b3700b5a19 | |||
| 2b29682095 | |||
| 286094ee33 | |||
| c7a061d24e | |||
| 4bdc2e0aba | |||
| e69061082b | |||
| 1da2ec3cb1 | |||
| 8ffc3b80b2 | |||
| 08f8b6cb8e | |||
| fb09c9692c | |||
| 4901b0c78f | |||
| 0a2117241f | |||
| f352cfd15b | |||
| ac7c5c1064 | |||
| 077cb2103d | |||
| cdeda011a4 | |||
| 894c146988 | |||
| 61cc9e151f | |||
| cfe4c9ff21 | |||
| d4bd29b320 | |||
| 7f2f2056c3 | |||
| 4dd3b2cfb7 | |||
| 2e62ad0f00 | |||
| 41ef292b82 | |||
| aa60671c88 | |||
| f1ccba39e8 | |||
| 226e580a30 | |||
| 6f8e719200 | |||
| c127af1e05 | |||
| 648904cc69 | |||
| dc85ddb3f9 | |||
| 23a7d8555f | |||
| bc9e250d34 | |||
| 2203186527 | |||
| 53d3d9ecb8 | |||
| de549f67a1 | |||
| 755c41481a | |||
| aebc2126bc | |||
| f43547fb31 | |||
| 398e4df7cf | |||
| ff68efc3f5 | |||
| 8ba2f51bda | |||
| 87b79b278b | |||
| 121e3ea9be | |||
| ec6ed79ee1 | |||
| ca125826a7 | |||
| dd0aecf108 | |||
| ef5cb2f0cd | |||
| e5a7bb40e9 | |||
| bfdda48fee | |||
| ebea1bb5c1 | |||
| 14bc1552fc | |||
| a5b80d3944 | |||
| 75d0eedc2b | |||
| 29ac7028fa | |||
| 8a63b61495 | |||
| eb9e6443e2 | |||
| 362c466a16 | |||
| 5dac42646b | |||
| c25faf6426 | |||
| 81df1245b4 | |||
| 2bf4d92185 | |||
| ae6073fe80 | |||
| d0463da2a1 | |||
| c0f8001627 | |||
| f39bf61b04 | |||
| 9c8237dab0 | |||
| b88251fa79 | |||
| 208855917e | |||
| 34bdf450e9 | |||
| 998fa1f4e9 | |||
| 5c80f7d58c | |||
| 7552181e24 | |||
| 4b2e26050e | |||
| 530b48de71 | |||
| f4721901f8 | |||
| 8b692269c1 | |||
| 079eca7b4d | |||
| fee40cdbe2 | |||
| 66920bb4cb | |||
| fdbf810aa2 | |||
| 08bfc1de4a | |||
| 76149328fe | |||
| 285f33f3f1 | |||
| b17c1b7588 | |||
| 5b25c90db8 | |||
| 931a0a5168 | |||
| f6a46438bd | |||
| 4a60ec1755 | |||
| ec222413dd | |||
| 5a28239813 | |||
| da45cba2ff | |||
| 54bc34496a | |||
| 294910ac84 | |||
| 71d2c6a5d5 | |||
| 79bf17fe24 | |||
| 31f66031bc | |||
| d3f2284791 | |||
| ec647608c4 | |||
| 597582ddd8 | |||
| c6d9889182 | |||
| 7c58234174 | |||
| ae9282b0af | |||
| 310ba646fc | |||
| d479908939 | |||
| 5cd5d68d22 | |||
| 3e0bf25acb | |||
| f3d277c94a | |||
| 04545ecbb0 | |||
| 5350651d6f | |||
| f2e2e28419 | |||
| b9031785ac | |||
| 91a72474a1 | |||
| b6e7c425c6 | |||
| 834ace4566 | |||
| 54af70005d | |||
| f2bf168925 | |||
| 27ffbd8dec | |||
| eaa82592fe | |||
| 73784585a8 | |||
| 262d562dd9 | |||
| ab4f904dc9 | |||
| fc4fdd5ee2 | |||
| 41c5b62b1a | |||
| 239cb0435c | |||
| c6ccc7a6e2 | |||
| 6cedb9019c | |||
| 8bc64f0438 | |||
| 89e6e39e58 | |||
| 645761f677 | |||
| 0fc60f7855 | |||
| ce38460d87 | |||
| de8e759d3a | |||
| 06f6134538 | |||
| ac352b3a23 | |||
| 9b8e65e552 | |||
| 35999a05f0 | |||
| 86ee30e9b4 | |||
| a81c4a1e23 | |||
| 394ce458a0 | |||
| f187e57899 | |||
| a15335872d | |||
| beb77b4dab | |||
| aa80d8cd0a | |||
| 77d197f14e | |||
| f98fbb778c | |||
| c46a0106f2 | |||
| cbf3db0be0 | |||
| 21f3710083 | |||
| 8ac5768f4f | |||
| 2458b9305c | |||
| a8909ea2a5 | |||
| ac7c35c6c2 | |||
| e4631b5a85 | |||
| e1c50b5dc5 | |||
| c6c5a5cd12 | |||
| bd4854a607 | |||
| cd0181e6f4 | |||
| 287b1d2b4d | |||
| 10c61bb0a7 | |||
| 92215ac34f | |||
| f64d50d8c8 | |||
| b74e0ce48f | |||
| 27cb0029a8 | |||
| ce6f193f06 | |||
| a862ffdde4 | |||
| 3f1cd8a118 | |||
| bb4b5838e3 | |||
| ea98d64184 | |||
| 98f3c56da5 | |||
| 20b7619380 | |||
| 7b1c3f05c7 | |||
| 9166998442 | |||
| e1f6b577bf | |||
| ba0d08b2a6 | |||
| e79c12a038 | |||
| 2ca5182a28 | |||
| 205e627209 | |||
| 425d4f3f63 | |||
| d69843e122 | |||
| d2586d3b59 | |||
| edab84c89b | |||
| dd08754f1f | |||
| 2cdfe85091 | |||
| a11acef36f | |||
| 1e34dbf616 | |||
| b3d4763ef6 | |||
| fe630e9383 | |||
| 826a20785f | |||
| 75932d7621 | |||
| 62d095af4f | |||
| 1594735aa0 | |||
| cbd0bdf9fc | |||
| d3e8e8fb9c | |||
| 66406c5a48 | |||
| 753c600dd2 | |||
| b28b1df348 | |||
| b94649162e | |||
| ee50e19dbd | |||
| cc23f8b831 | |||
| bac7b68bb1 | |||
| f9a622c89b | |||
| c321dc5e81 | |||
| 72f37c9df4 | |||
| 544eac0c8a | |||
| 823593ddae | |||
| 3600e704c4 | |||
| 0c79d756a4 | |||
| eb531a7a88 | |||
| d6634d30dc | |||
| f87806b1b4 | |||
| 2a5afeb5ff | |||
| fc5495f1ec | |||
| 699cc361a2 | |||
| 31bf4f10c0 | |||
| fe704af62f | |||
| e74517543d | |||
| 44acf19742 | |||
| bf20aa253e | |||
| 81c815840d | |||
| e9cd63dc5f | |||
| 1ae8f67d93 | |||
| daa1e10333 | |||
| a8a356e703 | |||
| ca440cc5dd | |||
| 95a9fb4f62 | |||
| 7db9e27112 | |||
| 03bcdbe3f7 | |||
| f0762a6213 | |||
| 67fbc6b3ad | |||
| d9662d7396 | |||
| 5ccbbf259d | |||
| 179c2f8723 | |||
| c76e0a40a7 | |||
| 03407e528f | |||
| 0c41d742cf | |||
| ed2f471a4e | |||
| 04efec101e | |||
| a6c69012cc | |||
| 0045c54d8e | |||
| 45436c006f | |||
| cc183c0da8 | |||
| 523f1df98b | |||
| 5843dff278 | |||
| 7f24f47978 | |||
| b1f9fd459e | |||
| 48988eb785 | |||
| 0045a885b9 | |||
| 0b57f60454 | |||
| f0857c7da2 | |||
| 15faa2e841 | |||
| da103f7197 | |||
| 1d3e42f92e | |||
| 20ced841dd | |||
| 54ebd0a796 | |||
| e636a7171b | |||
| e8f847065b | |||
| 1c806bb572 | |||
| 963133598f | |||
| fedaa74c47 | |||
| e322baf1d7 | |||
| 173a07cb59 | |||
| 364afff860 | |||
| 1b59e61b8e | |||
| b1f453f7ba | |||
| 175e842feb | |||
| d7a9a37a0e | |||
| 836b9240de | |||
| bdac2df4b9 | |||
| 57b507ad50 | |||
| 35201b69f6 | |||
| 0d138c26e9 | |||
| b4a7393dca | |||
| d86092df1a | |||
| b392d7f8e3 | |||
| 7cc7953879 | |||
| 7b26852a1f | |||
| f26b384697 | |||
| ab0531aa76 | |||
| 6873720d81 | |||
| 1e30c4a219 | |||
| 0a0e3ff970 | |||
| 5c42fd86a6 | |||
| 16cc829906 | |||
| 829e7cf33c | |||
| 02bfa90417 | |||
| 0b2466cf26 | |||
| 9d8df04c5c | |||
| 34a1697d50 | |||
| 17cf711c3d | |||
| ce0b19605a | |||
| 35bd9ecda3 | |||
| ca89849dd2 | |||
| ac1cb6d56b | |||
| dfbffea0fc | |||
| 7ae9c993f1 | |||
| 91d739f8d6 | |||
| f0c625d85c | |||
| b5f5e73076 | |||
| 1fb5eff7f1 | |||
| 5116cfd141 | |||
| e53a1f90b0 | |||
| 766c9628b0 | |||
| 6a4abdd74c | |||
| fc8bc5ba1e | |||
| 0fde5d44c0 | |||
| dc6b5a3d49 | |||
| 396522f249 | |||
| 86ab39e4ca | |||
| a4c9cb0e55 | |||
| e6c6feac10 | |||
| ca0aee58ab | |||
| 6391f2c43d | |||
| 32171bb70c | |||
| fd6675a3a3 | |||
| 9d883978a8 | |||
| 1aae65575c | |||
| c5d58e1aab | |||
| 56394471fa | |||
| 4cae6959df | |||
| f02d7b4516 | |||
| f5c83112df | |||
| a413dc81c1 | |||
| c9eddab191 | |||
| ec1268bd71 | |||
| 22eb2b5823 | |||
| 9669da026f | |||
| 7b14e54eab | |||
| 6b30ee4593 | |||
| 17c47a15da | |||
| 8f55517236 | |||
| 41ad086dfa | |||
| e19ef7dcae | |||
| f361265d70 | |||
| ef72e3ef77 | |||
| 770f1a1ca0 | |||
| e8fc91191f | |||
| 105ad3317d | |||
| 22bf4775cd | |||
| 5c6be7969a | |||
| c6e23f4be2 | |||
| 05547c22ec | |||
| cc7ac79fa6 | |||
| 4c5c27dfc1 | |||
| 4aabfbd52e | |||
| 6eab842361 | |||
| b729dfd702 | |||
| 6366840781 | |||
| 704a2ee90b | |||
| 484be9bfe6 | |||
| a99e070c26 | |||
| bf803f88af | |||
| 9af6febca5 | |||
| 0101d0a1bd | |||
| 266874609d | |||
| 2ba7feedfc | |||
| 43c67b4939 | |||
| 2d9915e43a | |||
| 2329b41bce | |||
| 536496184e | |||
| 429c32477c | |||
| f5d51b2061 | |||
| 2ad1aaa277 | |||
| 3afd32dbc1 | |||
| 092830ed07 | |||
| d118a6d3ff | |||
| fe97ffdc2f | |||
| 964d2ce59c | |||
| dc52684cbc | |||
| 191bedc56f | |||
| 47b2ace7fd | |||
| 9fb7359a3e | |||
| 4a5de26406 | |||
| 6fa18e126f | |||
| 1149002e0c | |||
| d704cb0b50 | |||
| d59e5ae9cf | |||
| 4587c1550d | |||
| b5bd329ada | |||
| beccd7a4ac | |||
| 61262fa939 | |||
| 7c6b006631 | |||
| dbd149354a | |||
| 4306ba5004 | |||
| 6de370b82f | |||
| 45781666b8 | |||
| 538231eb6f | |||
| eb74f87f2c | |||
| 59d71ffdcf | |||
| d1b93d4011 | |||
| d8ddf2e740 | |||
| 581327dc8e | |||
| 76e4512a0c | |||
| efdd55beca | |||
| 2c115649b9 | |||
| 2ddcc31a93 | |||
| 3bcce5b749 | |||
| 80dac27214 | |||
| 4a1177d048 | |||
| 4725d8f270 | |||
| 07b3528515 | |||
| d2d1b1ea26 |
@@ -0,0 +1,176 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Follow the troubleshooting guide before reporting a bug
|
||||||
|
title: "[Issue]: "
|
||||||
|
labels: bug
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for taking the time to fill out this bug form!
|
||||||
|
|
||||||
|
**READ ME FIRST!**
|
||||||
|
If you're here because something basic is not working (like gamepad input, video, or similar), it's probably something specific to your setup, so make sure you've gone through the Troubleshooting Guide first: https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting
|
||||||
|
|
||||||
|
If you still have trouble with basic functionality after following the guide, join our Discord server where there are many other volunteers who can help (or direct you back here if it looks like a Moonlight bug after all). https://moonlight-stream.org/discord
|
||||||
|
- type: textarea
|
||||||
|
id: describe-bug
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A clear and concise description of what the bug is.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps-reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Any special steps that are required for the bug to appear.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: affected-games
|
||||||
|
attributes:
|
||||||
|
label: Affected games
|
||||||
|
description: List the games you've tried that exhibit the issue. To see if the issue is game-specific, try streaming Steam Big Picture with Moonlight and see if the issue persists there.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: other-clients
|
||||||
|
attributes:
|
||||||
|
label: Other Moonlight clients
|
||||||
|
description: Does the issue occur when using Moonlight on PC or iOS?
|
||||||
|
options:
|
||||||
|
- "PC"
|
||||||
|
- "iOS"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: settings-adjusted
|
||||||
|
attributes:
|
||||||
|
label: Moonlight adjusted settings
|
||||||
|
description: Have any settings been adjusted from defaults?
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: settings-adjusted-settings
|
||||||
|
attributes:
|
||||||
|
label: Moonlight adjusted settings (please complete the following information)
|
||||||
|
description: If the settings have been adjusted, which settings have been changed?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: settings-default
|
||||||
|
attributes:
|
||||||
|
label: Moonlight default settings
|
||||||
|
description: Does the problem still occur after reverting settings back to default?
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: gamepad-connected
|
||||||
|
attributes:
|
||||||
|
label: Gamepad-related connection issue
|
||||||
|
description: Do you have any gamepads connected to your host PC directly?
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: gamepad-on-screen
|
||||||
|
attributes:
|
||||||
|
label: Gamepad-related input issue
|
||||||
|
description: If gamepad input is not working, does it work if you use Moonlight's on-screen controls?
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: gamepad-test
|
||||||
|
attributes:
|
||||||
|
label: Gamepad-related streaming issue
|
||||||
|
description: |
|
||||||
|
Does the problem still remain if you stream the desktop and use https://html5gamepad.com to test your gamepad?
|
||||||
|
Instructions for streaming the desktop can be found here: https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: android
|
||||||
|
attributes:
|
||||||
|
label: Android version
|
||||||
|
description: What is the Android version?
|
||||||
|
placeholder: e.g. Android 10
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Device model
|
||||||
|
description: What is the device model?
|
||||||
|
placeholder: e.g. Samsung Galaxy S21
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: server-os
|
||||||
|
attributes:
|
||||||
|
label: Server PC OS version
|
||||||
|
description: What is the PC OS version?
|
||||||
|
placeholder: e.g. Windows 10 1809
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: server-geforce
|
||||||
|
attributes:
|
||||||
|
label: Server PC GeForce Experience version
|
||||||
|
description: What is the GeForce Experience version?
|
||||||
|
placeholder: e.g. 3.16.0.140
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: server-driver
|
||||||
|
attributes:
|
||||||
|
label: Server PC Nvidia GPU driver version
|
||||||
|
description: What is the Nvidia GPU driver version?
|
||||||
|
placeholder: e.g. 417.35
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: server-antivirus
|
||||||
|
attributes:
|
||||||
|
label: Server PC antivirus and firewall software
|
||||||
|
description: Which antivirus and firewall software are installed on the Server PC?
|
||||||
|
placeholder: e.g. Windows Defender and Windows Firewall
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: If applicable, add screenshots to help explain your problem. If the issue is related to video glitching or poor quality, please include screenshots.
|
||||||
|
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
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Anything else you think may be relevant to the issue or special about your specific setup.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: "[Feature request]: "
|
||||||
|
labels: enhancement
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for taking the time to fill out this feature form!
|
||||||
|
- type: textarea
|
||||||
|
id: feature
|
||||||
|
attributes:
|
||||||
|
label: Is your feature request related to a problem? Please describe.
|
||||||
|
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Describe the solution you'd like
|
||||||
|
description: A clear and concise description of what you want to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Describe alternatives you've considered
|
||||||
|
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: Add any other context or screenshots about the feature request here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*.ap_
|
*.ap_
|
||||||
*.aab
|
*.aab
|
||||||
output.json
|
output.json
|
||||||
|
output-metadata.json
|
||||||
out/
|
out/
|
||||||
|
|
||||||
# files for the dex VM
|
# files for the dex VM
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"MD013": false
|
||||||
|
}
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
language: android
|
|
||||||
dist: trusty
|
|
||||||
|
|
||||||
git:
|
|
||||||
depth: 1
|
|
||||||
|
|
||||||
android:
|
|
||||||
components:
|
|
||||||
- tools
|
|
||||||
- platform-tools
|
|
||||||
- build-tools-29.0.3
|
|
||||||
- android-29
|
|
||||||
|
|
||||||
before_install:
|
|
||||||
- sdkmanager --list
|
|
||||||
|
|
||||||
install:
|
|
||||||
- yes | sdkmanager "ndk;20.0.5594570"
|
|
||||||
@@ -1,56 +1,61 @@
|
|||||||
# Moonlight Android
|
# Moonlight Android
|
||||||
|
|
||||||
[](https://travis-ci.org/moonlight-stream/moonlight-android)
|
[](https://ci.appveyor.com/project/cgutman/moonlight-android/branch/master)
|
||||||
|
[](https://hosted.weblate.org/projects/moonlight/moonlight-android/)
|
||||||
|
|
||||||
[Moonlight](https://moonlight-stream.org) is an open source implementation of NVIDIA's GameStream, as used by the NVIDIA Shield.
|
[Moonlight for Android](https://moonlight-stream.org) is an open source client for NVIDIA GameStream and [Sunshine](https://github.com/LizardByte/Sunshine).
|
||||||
We reverse engineered the Shield streaming software and created a version that can be run on any Android device.
|
|
||||||
|
|
||||||
Moonlight will allow you to stream your full collection of games from your Windows PC to your Android device,
|
Moonlight for Android will allow you to stream your full collection of games from your Windows PC to your Android device,
|
||||||
whether in your own home or over the internet.
|
whether in your own home or over the internet.
|
||||||
|
|
||||||
Check our [wiki](https://github.com/moonlight-stream/moonlight-docs/wiki) for more detailed information or a troubleshooting guide.
|
Moonlight also has a [PC client](https://github.com/moonlight-stream/moonlight-qt) and [iOS/tvOS client](https://github.com/moonlight-stream/moonlight-ios).
|
||||||
|
|
||||||
## Features
|
You can follow development on our [Discord server](https://moonlight-stream.org/discord) and help translate Moonlight into your language on [Weblate](https://hosted.weblate.org/projects/moonlight/moonlight-android/).
|
||||||
|
|
||||||
* Streams any of your games from your PC to your Android device
|
## Downloads
|
||||||
* Full gamepad support for MOGA, Xbox 360, PS3, OUYA, and Shield
|
|
||||||
* Automatically finds GameStream-compatible PCs on your network
|
|
||||||
|
|
||||||
## Installation
|
* [Google Play Store](https://play.google.com/store/apps/details?id=com.limelight)
|
||||||
|
* [Amazon App Store](https://www.amazon.com/gp/product/B00JK4MFN2)
|
||||||
* Download and install Moonlight for Android from
|
* [F-Droid](https://f-droid.org/packages/com.limelight)
|
||||||
[Google Play](https://play.google.com/store/apps/details?id=com.limelight), [F-Droid](https://f-droid.org/packages/com.limelight/), [Amazon App Store](http://www.amazon.com/gp/product/B00JK4MFN2), or directly from the [releases page](https://github.com/moonlight-stream/moonlight-android/releases)
|
* [APK](https://github.com/moonlight-stream/moonlight-android/releases)
|
||||||
* Download [GeForce Experience](http://www.geforce.com/geforce-experience) and install on your Windows PC
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
* [GameStream compatible](http://shield.nvidia.com/play-pc-games/) computer with an NVIDIA GeForce GTX 600 series or higher desktop or mobile GPU (GT-series and AMD GPUs not supported)
|
|
||||||
* Android device running 4.1 (Jelly Bean) or higher
|
|
||||||
* High-end wireless router (802.11n dual-band recommended)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
* Turn on GameStream in the GFE settings
|
|
||||||
* If you are connecting from outside the same network, turn on internet
|
|
||||||
streaming
|
|
||||||
* When on the same network as your PC, open Moonlight and tap on your PC in the list
|
|
||||||
* Accept the pairing confirmation on your PC and add the PIN if needed
|
|
||||||
* Tap your PC again to view the list of apps to stream
|
|
||||||
* Play games!
|
|
||||||
|
|
||||||
## Contribute
|
|
||||||
|
|
||||||
This project is being actively developed at [XDA Developers](http://forum.xda-developers.com/showthread.php?t=2505510)
|
|
||||||
|
|
||||||
1. Fork us
|
|
||||||
2. Write code
|
|
||||||
3. Send Pull Requests
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
* Install Android Studio and the Android NDK
|
* Install Android Studio and the Android NDK
|
||||||
* Run ‘git submodule update --init --recursive’ from within moonlight-android/
|
* Run ‘git submodule update --init --recursive’ from within moonlight-android/
|
||||||
* In moonlight-android/, create a file called ‘local.properties’. Add an ‘ndk.dir=’ property to the local.properties file and set it equal to your NDK directory.
|
* In moonlight-android/, create a file called ‘local.properties’. Add an ‘ndk.dir=’ property to the local.properties file and set it equal to your NDK directory.
|
||||||
* Build the APK using Android Studio
|
* Build the APK using Android Studio or gradle
|
||||||
|
|
||||||
|
## Building with Nix
|
||||||
|
|
||||||
|
A `flake.nix` provides a reproducible dev environment and a fully hermetic
|
||||||
|
debug-APK build (Android SDK 34, NDK 27.0.12077973, Gradle, JDK 17). All
|
||||||
|
commands need `?submodules=1` so the `moonlight-common-c` + `enet` submodule
|
||||||
|
sources are included in the build.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Dev shell: ANDROID_HOME / ANDROID_NDK_ROOT / JAVA_HOME + gradle, then build by hand
|
||||||
|
nix develop '.?submodules=1'
|
||||||
|
./gradlew assembleNonRootDebug
|
||||||
|
|
||||||
|
# Hermetic, offline build of the nonRoot debug APK (output in ./result/)
|
||||||
|
nix build '.?submodules=1#moonlight-android'
|
||||||
|
ls result/app-nonRoot-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Maven dependencies are pinned in `deps.json` (a Nixpkgs Gradle mitm-cache
|
||||||
|
lockfile). Regenerate it after changing dependencies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build '.?submodules=1#moonlight-android.mitmCache.updateScript' && ./result
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
* If your Nix is configured to offload to a remote builder, add `--builders ""`
|
||||||
|
to build locally instead of copying the multi-GB SDK closure to the remote.
|
||||||
|
* The build targets the `nonRoot` `debug` variant; it is signed with the
|
||||||
|
generated Android debug keystore (not a release key).
|
||||||
|
|
||||||
## Authors
|
## Authors
|
||||||
|
|
||||||
|
|||||||
+39
-17
@@ -1,23 +1,34 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 29
|
ndkVersion "27.0.12077973"
|
||||||
|
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
namespace 'com.limelight'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion 16
|
minSdk 21
|
||||||
targetSdkVersion 29
|
targetSdk 34
|
||||||
|
|
||||||
versionName "9.5"
|
versionName "12.1"
|
||||||
versionCode = 224
|
versionCode = 314
|
||||||
|
|
||||||
|
// Generate native debug symbols to allow Google Play to symbolicate our native crashes
|
||||||
|
ndk.debugSymbolLevel = 'FULL'
|
||||||
}
|
}
|
||||||
|
|
||||||
flavorDimensions "root"
|
flavorDimensions.add("root")
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
productFlavors {
|
productFlavors {
|
||||||
root {
|
root {
|
||||||
// Android O has native mouse capture, so don't show the rooted
|
// Android O has native mouse capture, so don't show the rooted
|
||||||
// version to devices running O on the Play Store.
|
// version to devices running O on the Play Store.
|
||||||
maxSdkVersion 25
|
maxSdk 25
|
||||||
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
ndkBuild {
|
ndkBuild {
|
||||||
@@ -27,6 +38,7 @@ android {
|
|||||||
|
|
||||||
applicationId "com.limelight.root"
|
applicationId "com.limelight.root"
|
||||||
dimension "root"
|
dimension "root"
|
||||||
|
buildConfigField "boolean", "ROOT_BUILD", "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
nonRoot {
|
nonRoot {
|
||||||
@@ -38,12 +50,19 @@ android {
|
|||||||
|
|
||||||
applicationId "com.limelight"
|
applicationId "com.limelight"
|
||||||
dimension "root"
|
dimension "root"
|
||||||
|
buildConfigField "boolean", "ROOT_BUILD", "false"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lintOptions {
|
compileOptions {
|
||||||
|
encoding "UTF-8"
|
||||||
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
disable 'MissingTranslation'
|
disable 'MissingTranslation'
|
||||||
lintConfig file("lint.xml")
|
lintConfig file('lint.xml')
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle {
|
bundle {
|
||||||
@@ -53,7 +72,7 @@ android {
|
|||||||
enableSplit = false
|
enableSplit = false
|
||||||
}
|
}
|
||||||
density {
|
density {
|
||||||
// FIXME: This should not be neccessary but we get
|
// FIXME: This should not be necessary but we get
|
||||||
// weird crashes due to missing drawable resources
|
// weird crashes due to missing drawable resources
|
||||||
// when this split is enabled.
|
// when this split is enabled.
|
||||||
enableSplit = false
|
enableSplit = false
|
||||||
@@ -63,9 +82,10 @@ android {
|
|||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix ".debug"
|
applicationIdSuffix ".debug"
|
||||||
|
resValue "string", "app_label", "Moonlight (Debug)"
|
||||||
|
resValue "string", "app_label_root", "Moonlight (Root Debug)"
|
||||||
|
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
useProguard false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
@@ -100,6 +120,8 @@ android {
|
|||||||
//
|
//
|
||||||
// TL;DR: Leave the following line alone!
|
// TL;DR: Leave the following line alone!
|
||||||
applicationIdSuffix ".unofficial"
|
applicationIdSuffix ".unofficial"
|
||||||
|
resValue "string", "app_label", "Moonlight"
|
||||||
|
resValue "string", "app_label_root", "Moonlight (Root)"
|
||||||
|
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
@@ -114,10 +136,10 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.bouncycastle:bcprov-jdk15on:1.64'
|
implementation 'org.bouncycastle:bcprov-jdk18on:1.77'
|
||||||
implementation 'org.bouncycastle:bcpkix-jdk15on:1.64'
|
implementation 'org.bouncycastle:bcpkix-jdk18on:1.77'
|
||||||
implementation 'org.jcodec:jcodec:0.2.3'
|
implementation 'org.jcodec:jcodec:0.2.5'
|
||||||
implementation 'com.squareup.okhttp3:okhttp:3.12.10'
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
implementation 'com.squareup.okio:okio:1.17.5'
|
implementation 'org.jmdns:jmdns:3.5.9'
|
||||||
implementation 'org.jmdns:jmdns:3.5.5'
|
implementation 'com.github.cgutman:ShieldControllerExtensions:1.0.1'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
|
|
||||||
<string name="app_label" translatable="false">Moonlight (Debug)</string>
|
|
||||||
<string name="app_label_root" translatable="false">Moonlight (Root Debug)</string>
|
|
||||||
|
|
||||||
</resources>
|
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="com.limelight">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA"/>
|
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA"/>
|
||||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||||
|
|
||||||
|
<!-- We don't need a MulticastLock on API level 34+ because we use NsdManager for mDNS -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"
|
||||||
|
android:maxSdkVersion="33" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
@@ -26,6 +29,12 @@
|
|||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.software.leanback"
|
android:name="android.software.leanback"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.sensor.accelerometer"
|
||||||
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.sensor.gyroscope"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<!-- Disable legacy input emulation on ChromeOS -->
|
<!-- Disable legacy input emulation on ChromeOS -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
@@ -35,36 +44,57 @@
|
|||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:dataExtractionRules="@xml/backup_rules_s"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:isGame="true"
|
android:isGame="true"
|
||||||
android:banner="@drawable/atv_banner"
|
android:banner="@drawable/atv_banner"
|
||||||
android:appCategory="game"
|
android:appCategory="game"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
|
||||||
android:installLocation="auto"
|
android:installLocation="auto"
|
||||||
|
android:gwpAsanMode="always"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name=".PosterContentProvider"
|
android:name=".PosterContentProvider"
|
||||||
android:authorities="poster.${applicationId}"
|
android:authorities="poster.${applicationId}"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<!-- Samsung multi-window support -->
|
<!-- Samsung multi-window support -->
|
||||||
<uses-library
|
<uses-library
|
||||||
android:name="com.sec.android.app.multiwindow"
|
android:name="com.sec.android.app.multiwindow"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.sec.android.support.multiwindow"
|
android:name="com.sec.android.support.multiwindow"
|
||||||
android:value="true" />
|
android:value="true" />
|
||||||
|
|
||||||
|
<!-- Disable Game Mode downscaling since it can break our UI dialogs and doesn't benefit
|
||||||
|
performance much for us since we don't use GL/Vulkan for rendering anyway -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.android.graphics.intervention.wm.allowDownscale"
|
||||||
|
android:value="false"/>
|
||||||
|
|
||||||
|
<!-- Game Mode configuration -->
|
||||||
|
<meta-data
|
||||||
|
android:name="android.game_mode_config"
|
||||||
|
android:resource="@xml/game_mode_config" />
|
||||||
|
|
||||||
<!-- Samsung DeX support requires explicit placement of android:resizeableActivity="true"
|
<!-- Samsung DeX support requires explicit placement of android:resizeableActivity="true"
|
||||||
in each activity even though it is implied by targeting API 24+ -->
|
in each activity even though it is implied by targeting API 24+ -->
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".PcView"
|
android:name=".PcView"
|
||||||
|
android:exported="true"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
||||||
|
|
||||||
|
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="system-default" />
|
||||||
|
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
@@ -80,50 +110,61 @@
|
|||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
||||||
<meta-data
|
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="system-default" />
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
|
||||||
android:value="com.limelight.PcView" />
|
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".AppView"
|
android:name=".AppView"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
||||||
<meta-data
|
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="system-default" />
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
|
||||||
android:value="com.limelight.PcView" />
|
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".preferences.StreamSettings"
|
android:name=".preferences.StreamSettings"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection"
|
||||||
android:label="Streaming Settings">
|
android:label="Streaming Settings">
|
||||||
<meta-data
|
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="system-default" />
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
|
||||||
android:value="com.limelight.PcView" />
|
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".preferences.AddComputerManually"
|
android:name=".preferences.AddComputerManually"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:windowSoftInputMode="stateVisible"
|
android:windowSoftInputMode="stateVisible"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection"
|
||||||
android:label="Add Computer Manually">
|
android:label="Add Computer Manually">
|
||||||
<meta-data
|
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="system-default" />
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
|
||||||
android:value="com.limelight.PcView" />
|
|
||||||
</activity>
|
</activity>
|
||||||
<!-- This will fall back to sensorLandscape at runtime on Android 4.2 and below -->
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".Game"
|
android:name=".Game"
|
||||||
android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection"
|
android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection"
|
||||||
android:screenOrientation="userLandscape"
|
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
android:supportsPictureInPicture="true"
|
android:supportsPictureInPicture="true"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:theme="@style/StreamTheme">
|
android:theme="@style/StreamTheme"
|
||||||
|
android:preferMinimalPostProcessing="true">
|
||||||
|
|
||||||
|
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="system-default" />
|
||||||
|
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
|
||||||
|
|
||||||
|
<!-- Special metadata for NVIDIA Shield devices to prevent input buffering
|
||||||
|
and most importantly, opt out of mouse acceleration while streaming -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
android:name="com.nvidia.immediateInput"
|
||||||
android:value="com.limelight.AppView" />
|
android:value="true" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.nvidia.rawCursorInput"
|
||||||
|
android:value="true" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
@@ -139,10 +180,10 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".HelpActivity"
|
android:name=".HelpActivity"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
||||||
<meta-data
|
<meta-data android:name="WindowManagerPreference:FreeformWindowSize" android:value="system-default" />
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
<meta-data android:name="WindowManagerPreference:FreeformWindowOrientation" android:value="landscape" />
|
||||||
android:value="com.limelight.PcView" />
|
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.limelight;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.StringReader;
|
import java.io.StringReader;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import com.limelight.computers.ComputerManagerListener;
|
import com.limelight.computers.ComputerManagerListener;
|
||||||
@@ -26,6 +27,7 @@ import android.app.Service;
|
|||||||
import android.content.ComponentName;
|
import android.content.ComponentName;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.ServiceConnection;
|
import android.content.ServiceConnection;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
import android.content.res.Configuration;
|
import android.content.res.Configuration;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.drawable.BitmapDrawable;
|
import android.graphics.drawable.BitmapDrawable;
|
||||||
@@ -59,17 +61,22 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
private int lastRunningAppId;
|
private int lastRunningAppId;
|
||||||
private boolean suspendGridUpdates;
|
private boolean suspendGridUpdates;
|
||||||
private boolean inForeground;
|
private boolean inForeground;
|
||||||
|
private boolean showHiddenApps;
|
||||||
|
private HashSet<Integer> hiddenAppIds = new HashSet<>();
|
||||||
|
|
||||||
private final static int START_OR_RESUME_ID = 1;
|
private final static int START_OR_RESUME_ID = 1;
|
||||||
private final static int QUIT_ID = 2;
|
private final static int QUIT_ID = 2;
|
||||||
private final static int CANCEL_ID = 3;
|
|
||||||
private final static int START_WITH_QUIT = 4;
|
private final static int START_WITH_QUIT = 4;
|
||||||
private final static int VIEW_DETAILS_ID = 5;
|
private final static int VIEW_DETAILS_ID = 5;
|
||||||
private final static int CREATE_SHORTCUT_ID = 6;
|
private final static int CREATE_SHORTCUT_ID = 6;
|
||||||
|
private final static int HIDE_APP_ID = 7;
|
||||||
|
|
||||||
|
public final static String HIDDEN_APPS_PREF_FILENAME = "HiddenApps";
|
||||||
|
|
||||||
public final static String NAME_EXTRA = "Name";
|
public final static String NAME_EXTRA = "Name";
|
||||||
public final static String UUID_EXTRA = "UUID";
|
public final static String UUID_EXTRA = "UUID";
|
||||||
public final static String NEW_PAIR_EXTRA = "NewPair";
|
public final static String NEW_PAIR_EXTRA = "NewPair";
|
||||||
|
public final static String SHOW_HIDDEN_APPS_EXTRA = "ShowHiddenApps";
|
||||||
|
|
||||||
private ComputerManagerService.ComputerManagerBinder managerBinder;
|
private ComputerManagerService.ComputerManagerBinder managerBinder;
|
||||||
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
||||||
@@ -98,13 +105,16 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
try {
|
try {
|
||||||
appGridAdapter = new AppGridAdapter(AppView.this,
|
appGridAdapter = new AppGridAdapter(AppView.this,
|
||||||
PreferenceConfiguration.readPreferences(AppView.this),
|
PreferenceConfiguration.readPreferences(AppView.this),
|
||||||
computer, localBinder.getUniqueId());
|
computer, localBinder.getUniqueId(),
|
||||||
|
showHiddenApps);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
finish();
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appGridAdapter.updateHiddenApps(hiddenAppIds, true);
|
||||||
|
|
||||||
// Now make the binder visible. We must do this after appGridAdapter
|
// Now make the binder visible. We must do this after appGridAdapter
|
||||||
// is set to prevent us from reaching updateUiWithServerinfo() and
|
// is set to prevent us from reaching updateUiWithServerinfo() and
|
||||||
// touching the appGridAdapter prior to initialization.
|
// touching the appGridAdapter prior to initialization.
|
||||||
@@ -283,10 +293,21 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
|
|
||||||
setContentView(R.layout.activity_app_view);
|
setContentView(R.layout.activity_app_view);
|
||||||
|
|
||||||
|
// Allow floating expanded PiP overlays while browsing apps
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
setShouldDockBigOverlays(false);
|
||||||
|
}
|
||||||
|
|
||||||
UiHelper.notifyNewRootView(this);
|
UiHelper.notifyNewRootView(this);
|
||||||
|
|
||||||
|
showHiddenApps = getIntent().getBooleanExtra(SHOW_HIDDEN_APPS_EXTRA, false);
|
||||||
uuidString = getIntent().getStringExtra(UUID_EXTRA);
|
uuidString = getIntent().getStringExtra(UUID_EXTRA);
|
||||||
|
|
||||||
|
SharedPreferences hiddenAppsPrefs = getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE);
|
||||||
|
for (String hiddenAppIdStr : hiddenAppsPrefs.getStringSet(uuidString, new HashSet<String>())) {
|
||||||
|
hiddenAppIds.add(Integer.parseInt(hiddenAppIdStr));
|
||||||
|
}
|
||||||
|
|
||||||
String computerName = getIntent().getStringExtra(NAME_EXTRA);
|
String computerName = getIntent().getStringExtra(NAME_EXTRA);
|
||||||
|
|
||||||
TextView label = findViewById(R.id.appListText);
|
TextView label = findViewById(R.id.appListText);
|
||||||
@@ -298,6 +319,21 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
Service.BIND_AUTO_CREATE);
|
Service.BIND_AUTO_CREATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateHiddenApps(boolean hideImmediately) {
|
||||||
|
HashSet<String> hiddenAppIdStringSet = new HashSet<>();
|
||||||
|
|
||||||
|
for (Integer hiddenAppId : hiddenAppIds) {
|
||||||
|
hiddenAppIdStringSet.add(hiddenAppId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putStringSet(uuidString, hiddenAppIdStringSet)
|
||||||
|
.apply();
|
||||||
|
|
||||||
|
appGridAdapter.updateHiddenApps(hiddenAppIds, hideImmediately);
|
||||||
|
}
|
||||||
|
|
||||||
private void populateAppGridWithCache() {
|
private void populateAppGridWithCache() {
|
||||||
try {
|
try {
|
||||||
// Try to load from cache
|
// Try to load from cache
|
||||||
@@ -355,9 +391,12 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
@Override
|
@Override
|
||||||
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
|
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
|
||||||
super.onCreateContextMenu(menu, v, menuInfo);
|
super.onCreateContextMenu(menu, v, menuInfo);
|
||||||
|
|
||||||
AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo;
|
AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo;
|
||||||
AppObject selectedApp = (AppObject) appGridAdapter.getItem(info.position);
|
AppObject selectedApp = (AppObject) appGridAdapter.getItem(info.position);
|
||||||
|
|
||||||
|
menu.setHeaderTitle(selectedApp.app.getAppName());
|
||||||
|
|
||||||
if (lastRunningAppId != 0) {
|
if (lastRunningAppId != 0) {
|
||||||
if (lastRunningAppId == selectedApp.app.getAppId()) {
|
if (lastRunningAppId == selectedApp.app.getAppId()) {
|
||||||
menu.add(Menu.NONE, START_OR_RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume));
|
menu.add(Menu.NONE, START_OR_RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume));
|
||||||
@@ -365,10 +404,17 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
menu.add(Menu.NONE, START_WITH_QUIT, 1, getResources().getString(R.string.applist_menu_quit_and_start));
|
menu.add(Menu.NONE, START_WITH_QUIT, 1, getResources().getString(R.string.applist_menu_quit_and_start));
|
||||||
menu.add(Menu.NONE, CANCEL_ID, 2, getResources().getString(R.string.applist_menu_cancel));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
menu.add(Menu.NONE, VIEW_DETAILS_ID, 3, getResources().getString(R.string.applist_menu_details));
|
|
||||||
|
// Only show the hide checkbox if this is not the currently running app or it's already hidden
|
||||||
|
if (lastRunningAppId != selectedApp.app.getAppId() || selectedApp.isHidden) {
|
||||||
|
MenuItem hideAppItem = menu.add(Menu.NONE, HIDE_APP_ID, 3, getResources().getString(R.string.applist_menu_hide_app));
|
||||||
|
hideAppItem.setCheckable(true);
|
||||||
|
hideAppItem.setChecked(selectedApp.isHidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.add(Menu.NONE, VIEW_DETAILS_ID, 4, getResources().getString(R.string.applist_menu_details));
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
// Only add an option to create shortcut if box art is loaded
|
// Only add an option to create shortcut if box art is loaded
|
||||||
@@ -379,7 +425,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
BitmapDrawable drawable = (BitmapDrawable)appImageView.getDrawable();
|
BitmapDrawable drawable = (BitmapDrawable)appImageView.getDrawable();
|
||||||
if (drawable != null && drawable.getBitmap() != null) {
|
if (drawable != null && drawable.getBitmap() != null) {
|
||||||
// We have a bitmap loaded too
|
// We have a bitmap loaded too
|
||||||
menu.add(Menu.NONE, CREATE_SHORTCUT_ID, 4, getResources().getString(R.string.applist_menu_scut));
|
menu.add(Menu.NONE, CREATE_SHORTCUT_ID, 5, getResources().getString(R.string.applist_menu_scut));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,12 +476,20 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
}, null);
|
}, null);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case CANCEL_ID:
|
case VIEW_DETAILS_ID:
|
||||||
|
Dialog.displayDialog(AppView.this, getResources().getString(R.string.title_details), app.app.toString(), false);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case VIEW_DETAILS_ID:
|
case HIDE_APP_ID:
|
||||||
Dialog.displayDialog(AppView.this, getResources().getString(R.string.title_details),
|
if (item.isChecked()) {
|
||||||
getResources().getString(R.string.applist_details_id) + " " + app.app.getAppId(), false);
|
// Transitioning hidden to shown
|
||||||
|
hiddenAppIds.remove(app.app.getAppId());
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Transitioning shown to hidden
|
||||||
|
hiddenAppIds.add(app.app.getAppId());
|
||||||
|
}
|
||||||
|
updateHiddenApps(false);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case CREATE_SHORTCUT_ID:
|
case CREATE_SHORTCUT_ID:
|
||||||
@@ -565,9 +619,8 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getAdapterFragmentLayoutId() {
|
public int getAdapterFragmentLayoutId() {
|
||||||
return PreferenceConfiguration.readPreferences(this).listMode ?
|
return PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ?
|
||||||
R.layout.list_view : (PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ?
|
R.layout.app_grid_view_small : R.layout.app_grid_view;
|
||||||
R.layout.app_grid_view_small : R.layout.app_grid_view);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -592,9 +645,10 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
listView.requestFocus();
|
listView.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AppObject {
|
public static class AppObject {
|
||||||
public final NvApp app;
|
public final NvApp app;
|
||||||
public boolean isRunning;
|
public boolean isRunning;
|
||||||
|
public boolean isHidden;
|
||||||
|
|
||||||
public AppObject(NvApp app) {
|
public AppObject(NvApp app) {
|
||||||
if (app == null) {
|
if (app == null) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,12 @@ package com.limelight;
|
|||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.webkit.WebView;
|
import android.webkit.WebView;
|
||||||
import android.webkit.WebViewClient;
|
import android.webkit.WebViewClient;
|
||||||
|
import android.window.OnBackInvokedCallback;
|
||||||
|
import android.window.OnBackInvokedDispatcher;
|
||||||
|
|
||||||
import com.limelight.utils.SpinnerDialog;
|
import com.limelight.utils.SpinnerDialog;
|
||||||
|
|
||||||
@@ -13,10 +16,26 @@ public class HelpActivity extends Activity {
|
|||||||
private SpinnerDialog loadingDialog;
|
private SpinnerDialog loadingDialog;
|
||||||
private WebView webView;
|
private WebView webView;
|
||||||
|
|
||||||
|
private boolean backCallbackRegistered;
|
||||||
|
private OnBackInvokedCallback onBackInvokedCallback;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
onBackInvokedCallback = new OnBackInvokedCallback() {
|
||||||
|
@Override
|
||||||
|
public void onBackInvoked() {
|
||||||
|
// We should always be able to go back because we unregister our callback
|
||||||
|
// when we can't go back. Nonetheless, we will still check anyway.
|
||||||
|
if (webView.canGoBack()) {
|
||||||
|
webView.goBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
webView = new WebView(this);
|
webView = new WebView(this);
|
||||||
setContentView(webView);
|
setContentView(webView);
|
||||||
|
|
||||||
@@ -39,6 +58,8 @@ public class HelpActivity extends Activity {
|
|||||||
getResources().getString(R.string.help_loading_title),
|
getResources().getString(R.string.help_loading_title),
|
||||||
getResources().getString(R.string.help_loading_msg), false);
|
getResources().getString(R.string.help_loading_msg), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshBackDispatchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -47,19 +68,41 @@ public class HelpActivity extends Activity {
|
|||||||
loadingDialog.dismiss();
|
loadingDialog.dismiss();
|
||||||
loadingDialog = null;
|
loadingDialog = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
refreshBackDispatchState();
|
||||||
public boolean shouldOverrideUrlLoading(WebView view, String url) {
|
|
||||||
return !(url.toUpperCase().startsWith("https://github.com/moonlight-stream/moonlight-docs/wiki/".toUpperCase()) ||
|
|
||||||
url.toUpperCase().startsWith("http://github.com/moonlight-stream/moonlight-docs/wiki/".toUpperCase()));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
webView.loadUrl(getIntent().getData().toString());
|
webView.loadUrl(getIntent().getData().toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void refreshBackDispatchState() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (webView.canGoBack() && !backCallbackRegistered) {
|
||||||
|
getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
|
||||||
|
OnBackInvokedDispatcher.PRIORITY_DEFAULT, onBackInvokedCallback);
|
||||||
|
backCallbackRegistered = true;
|
||||||
|
}
|
||||||
|
else if (!webView.canGoBack() && backCallbackRegistered) {
|
||||||
|
getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback);
|
||||||
|
backCallbackRegistered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (backCallbackRegistered) {
|
||||||
|
getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
// NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true"
|
||||||
public void onBackPressed() {
|
public void onBackPressed() {
|
||||||
// Back goes back through the WebView history
|
// Back goes back through the WebView history
|
||||||
// until no more history remains
|
// until no more history remains
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final static int APP_LIST_ID = 1;
|
|
||||||
private final static int PAIR_ID = 2;
|
private final static int PAIR_ID = 2;
|
||||||
private final static int UNPAIR_ID = 3;
|
private final static int UNPAIR_ID = 3;
|
||||||
private final static int WOL_ID = 4;
|
private final static int WOL_ID = 4;
|
||||||
@@ -117,12 +116,20 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
private final static int RESUME_ID = 6;
|
private final static int RESUME_ID = 6;
|
||||||
private final static int QUIT_ID = 7;
|
private final static int QUIT_ID = 7;
|
||||||
private final static int VIEW_DETAILS_ID = 8;
|
private final static int VIEW_DETAILS_ID = 8;
|
||||||
|
private final static int FULL_APP_LIST_ID = 9;
|
||||||
|
private final static int TEST_NETWORK_ID = 10;
|
||||||
|
private final static int GAMESTREAM_EOL_ID = 11;
|
||||||
|
|
||||||
private void initializeViews() {
|
private void initializeViews() {
|
||||||
setContentView(R.layout.activity_pc_view);
|
setContentView(R.layout.activity_pc_view);
|
||||||
|
|
||||||
UiHelper.notifyNewRootView(this);
|
UiHelper.notifyNewRootView(this);
|
||||||
|
|
||||||
|
// Allow floating expanded PiP overlays while browsing PCs
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
setShouldDockBigOverlays(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Set default preferences if we've never been run
|
// Set default preferences if we've never been run
|
||||||
PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
|
PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
|
||||||
|
|
||||||
@@ -154,6 +161,13 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Amazon review didn't like the help button because the wiki was not entirely
|
||||||
|
// navigable via the Fire TV remote (though the relevant parts were). Let's hide
|
||||||
|
// it on Fire TV.
|
||||||
|
if (getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) {
|
||||||
|
helpButton.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
getFragmentManager().beginTransaction()
|
getFragmentManager().beginTransaction()
|
||||||
.replace(R.id.pcFragmentContainer, new AdapterFragment())
|
.replace(R.id.pcFragmentContainer, new AdapterFragment())
|
||||||
.commitAllowingStateLoss();
|
.commitAllowingStateLoss();
|
||||||
@@ -246,6 +260,11 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
updateComputer(details);
|
updateComputer(details);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add a launcher shortcut for this PC (off the main thread to prevent ANRs)
|
||||||
|
if (details.pairState == PairState.PAIRED) {
|
||||||
|
shortcutHelper.createAppViewShortcutForOnlineHost(details);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -316,15 +335,36 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo;
|
AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo;
|
||||||
ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position);
|
ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position);
|
||||||
|
|
||||||
|
// Add a header with PC status details
|
||||||
|
menu.clearHeader();
|
||||||
|
String headerTitle = computer.details.name + " - ";
|
||||||
|
switch (computer.details.state)
|
||||||
|
{
|
||||||
|
case ONLINE:
|
||||||
|
headerTitle += getResources().getString(R.string.pcview_menu_header_online);
|
||||||
|
break;
|
||||||
|
case OFFLINE:
|
||||||
|
menu.setHeaderIcon(R.drawable.ic_pc_offline);
|
||||||
|
headerTitle += getResources().getString(R.string.pcview_menu_header_offline);
|
||||||
|
break;
|
||||||
|
case UNKNOWN:
|
||||||
|
headerTitle += getResources().getString(R.string.pcview_menu_header_unknown);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.setHeaderTitle(headerTitle);
|
||||||
|
|
||||||
// Inflate the context menu
|
// Inflate the context menu
|
||||||
if (computer.details.state == ComputerDetails.State.OFFLINE ||
|
if (computer.details.state == ComputerDetails.State.OFFLINE ||
|
||||||
computer.details.state == ComputerDetails.State.UNKNOWN) {
|
computer.details.state == ComputerDetails.State.UNKNOWN) {
|
||||||
menu.add(Menu.NONE, WOL_ID, 1, getResources().getString(R.string.pcview_menu_send_wol));
|
menu.add(Menu.NONE, WOL_ID, 1, getResources().getString(R.string.pcview_menu_send_wol));
|
||||||
menu.add(Menu.NONE, DELETE_ID, 2, getResources().getString(R.string.pcview_menu_delete_pc));
|
menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol));
|
||||||
}
|
}
|
||||||
else if (computer.details.pairState != PairState.PAIRED) {
|
else if (computer.details.pairState != PairState.PAIRED) {
|
||||||
menu.add(Menu.NONE, PAIR_ID, 1, getResources().getString(R.string.pcview_menu_pair_pc));
|
menu.add(Menu.NONE, PAIR_ID, 1, getResources().getString(R.string.pcview_menu_pair_pc));
|
||||||
menu.add(Menu.NONE, DELETE_ID, 2, getResources().getString(R.string.pcview_menu_delete_pc));
|
if (computer.details.nvidiaServer) {
|
||||||
|
menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (computer.details.runningGameId != 0) {
|
if (computer.details.runningGameId != 0) {
|
||||||
@@ -332,13 +372,16 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit));
|
menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit));
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.add(Menu.NONE, APP_LIST_ID, 3, getResources().getString(R.string.pcview_menu_app_list));
|
if (computer.details.nvidiaServer) {
|
||||||
|
menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 3, getResources().getString(R.string.pcview_menu_eol));
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME: We used to be able to unpair here but it's been broken since GFE 2.1.x, so I've replaced
|
menu.add(Menu.NONE, FULL_APP_LIST_ID, 4, getResources().getString(R.string.pcview_menu_app_list));
|
||||||
// it with delete which actually work
|
|
||||||
menu.add(Menu.NONE, DELETE_ID, 4, getResources().getString(R.string.pcview_menu_delete_pc));
|
|
||||||
}
|
}
|
||||||
menu.add(Menu.NONE, VIEW_DETAILS_ID, 5, getResources().getString(R.string.pcview_menu_details));
|
|
||||||
|
menu.add(Menu.NONE, TEST_NETWORK_ID, 5, getResources().getString(R.string.pcview_menu_test_network));
|
||||||
|
menu.add(Menu.NONE, DELETE_ID, 6, getResources().getString(R.string.pcview_menu_delete_pc));
|
||||||
|
menu.add(Menu.NONE, VIEW_DETAILS_ID, 7, getResources().getString(R.string.pcview_menu_details));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -350,15 +393,10 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void doPair(final ComputerDetails computer) {
|
private void doPair(final ComputerDetails computer) {
|
||||||
if (computer.state == ComputerDetails.State.OFFLINE ||
|
if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) {
|
||||||
ServerHelper.getCurrentAddressFromComputer(computer) == null) {
|
|
||||||
Toast.makeText(PcView.this, getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show();
|
Toast.makeText(PcView.this, getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (computer.runningGameId != 0) {
|
|
||||||
Toast.makeText(PcView.this, getResources().getString(R.string.pair_pc_ingame), Toast.LENGTH_LONG).show();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (managerBinder == null) {
|
if (managerBinder == null) {
|
||||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
|
Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
|
||||||
return;
|
return;
|
||||||
@@ -376,8 +414,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
stopComputerUpdates(true);
|
stopComputerUpdates(true);
|
||||||
|
|
||||||
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
||||||
managerBinder.getUniqueId(),
|
computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert,
|
||||||
computer.serverCert,
|
|
||||||
PlatformBinding.getCryptoProvider(PcView.this));
|
PlatformBinding.getCryptoProvider(PcView.this));
|
||||||
if (httpConn.getPairState() == PairState.PAIRED) {
|
if (httpConn.getPairState() == PairState.PAIRED) {
|
||||||
// Don't display any toast, but open the app list
|
// Don't display any toast, but open the app list
|
||||||
@@ -389,16 +426,22 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
|
|
||||||
// Spin the dialog off in a thread because it blocks
|
// Spin the dialog off in a thread because it blocks
|
||||||
Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title),
|
Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title),
|
||||||
getResources().getString(R.string.pair_pairing_msg)+" "+pinStr, false);
|
getResources().getString(R.string.pair_pairing_msg)+" "+pinStr+"\n\n"+
|
||||||
|
getResources().getString(R.string.pair_pairing_help), false);
|
||||||
|
|
||||||
PairingManager pm = httpConn.getPairingManager();
|
PairingManager pm = httpConn.getPairingManager();
|
||||||
|
|
||||||
PairState pairState = pm.pair(httpConn.getServerInfo(), pinStr);
|
PairState pairState = pm.pair(httpConn.getServerInfo(true), pinStr);
|
||||||
if (pairState == PairState.PIN_WRONG) {
|
if (pairState == PairState.PIN_WRONG) {
|
||||||
message = getResources().getString(R.string.pair_incorrect_pin);
|
message = getResources().getString(R.string.pair_incorrect_pin);
|
||||||
}
|
}
|
||||||
else if (pairState == PairState.FAILED) {
|
else if (pairState == PairState.FAILED) {
|
||||||
message = getResources().getString(R.string.pair_fail);
|
if (computer.runningGameId != 0) {
|
||||||
|
message = getResources().getString(R.string.pair_pc_ingame);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
message = getResources().getString(R.string.pair_fail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (pairState == PairState.ALREADY_IN_PROGRESS) {
|
else if (pairState == PairState.ALREADY_IN_PROGRESS) {
|
||||||
message = getResources().getString(R.string.pair_already_in_progress);
|
message = getResources().getString(R.string.pair_already_in_progress);
|
||||||
@@ -442,7 +485,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
|
|
||||||
if (toastSuccess) {
|
if (toastSuccess) {
|
||||||
// Open the app list after a successful pairing attempt
|
// Open the app list after a successful pairing attempt
|
||||||
doAppList(computer, true);
|
doAppList(computer, true, false);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Start polling again if we're still in the foreground
|
// Start polling again if we're still in the foreground
|
||||||
@@ -488,8 +531,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void doUnpair(final ComputerDetails computer) {
|
private void doUnpair(final ComputerDetails computer) {
|
||||||
if (computer.state == ComputerDetails.State.OFFLINE ||
|
if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) {
|
||||||
ServerHelper.getCurrentAddressFromComputer(computer) == null) {
|
|
||||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
|
Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -506,8 +548,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
String message;
|
String message;
|
||||||
try {
|
try {
|
||||||
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
||||||
managerBinder.getUniqueId(),
|
computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert,
|
||||||
computer.serverCert,
|
|
||||||
PlatformBinding.getCryptoProvider(PcView.this));
|
PlatformBinding.getCryptoProvider(PcView.this));
|
||||||
if (httpConn.getPairState() == PairingManager.PairState.PAIRED) {
|
if (httpConn.getPairState() == PairingManager.PairState.PAIRED) {
|
||||||
httpConn.unpair();
|
httpConn.unpair();
|
||||||
@@ -541,7 +582,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void doAppList(ComputerDetails computer, boolean newlyPaired) {
|
private void doAppList(ComputerDetails computer, boolean newlyPaired, boolean showHiddenGames) {
|
||||||
if (computer.state == ComputerDetails.State.OFFLINE) {
|
if (computer.state == ComputerDetails.State.OFFLINE) {
|
||||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
|
Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
@@ -555,6 +596,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
i.putExtra(AppView.NAME_EXTRA, computer.name);
|
i.putExtra(AppView.NAME_EXTRA, computer.name);
|
||||||
i.putExtra(AppView.UUID_EXTRA, computer.uuid);
|
i.putExtra(AppView.UUID_EXTRA, computer.uuid);
|
||||||
i.putExtra(AppView.NEW_PAIR_EXTRA, newlyPaired);
|
i.putExtra(AppView.NEW_PAIR_EXTRA, newlyPaired);
|
||||||
|
i.putExtra(AppView.SHOW_HIDDEN_APPS_EXTRA, showHiddenGames);
|
||||||
startActivity(i);
|
startActivity(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,8 +634,8 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
}, null);
|
}, null);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case APP_LIST_ID:
|
case FULL_APP_LIST_ID:
|
||||||
doAppList(computer.details, false);
|
doAppList(computer.details, false, true);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case RESUME_ID:
|
case RESUME_ID:
|
||||||
@@ -625,6 +667,14 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
Dialog.displayDialog(PcView.this, getResources().getString(R.string.title_details), computer.details.toString(), false);
|
Dialog.displayDialog(PcView.this, getResources().getString(R.string.title_details), computer.details.toString(), false);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
case TEST_NETWORK_ID:
|
||||||
|
ServerHelper.doNetworkTest(PcView.this);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case GAMESTREAM_EOL_ID:
|
||||||
|
HelpLauncher.launchGameStreamEolFaq(PcView.this);
|
||||||
|
return true;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return super.onContextItemSelected(item);
|
return super.onContextItemSelected(item);
|
||||||
}
|
}
|
||||||
@@ -635,6 +685,12 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
|
|
||||||
new DiskAssetLoader(this).deleteAssetsForComputer(details.uuid);
|
new DiskAssetLoader(this).deleteAssetsForComputer(details.uuid);
|
||||||
|
|
||||||
|
// Delete hidden games preference value
|
||||||
|
getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.remove(details.uuid)
|
||||||
|
.apply();
|
||||||
|
|
||||||
for (int i = 0; i < pcGridAdapter.getCount(); i++) {
|
for (int i = 0; i < pcGridAdapter.getCount(); i++) {
|
||||||
ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i);
|
ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i);
|
||||||
|
|
||||||
@@ -669,11 +725,6 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a launcher shortcut for this PC
|
|
||||||
if (details.pairState == PairState.PAIRED) {
|
|
||||||
shortcutHelper.createAppViewShortcutForOnlineHost(details);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingEntry != null) {
|
if (existingEntry != null) {
|
||||||
// Replace the information in the existing entry
|
// Replace the information in the existing entry
|
||||||
existingEntry.details = details;
|
existingEntry.details = details;
|
||||||
@@ -692,9 +743,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getAdapterFragmentLayoutId() {
|
public int getAdapterFragmentLayoutId() {
|
||||||
return PreferenceConfiguration.readPreferences(this).listMode ?
|
return R.layout.pc_grid_view;
|
||||||
R.layout.list_view : (PreferenceConfiguration.readPreferences(this).smallIconMode ?
|
|
||||||
R.layout.pc_grid_view_small : R.layout.pc_grid_view);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -713,7 +762,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
// Pair an unpaired machine by default
|
// Pair an unpaired machine by default
|
||||||
doPair(computer.details);
|
doPair(computer.details);
|
||||||
} else {
|
} else {
|
||||||
doAppList(computer.details, false);
|
doAppList(computer.details, false, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -721,7 +770,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
|||||||
registerForContextMenu(listView);
|
registerForContextMenu(listView);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ComputerObject {
|
public static class ComputerObject {
|
||||||
public ComputerDetails details;
|
public ComputerDetails details;
|
||||||
|
|
||||||
public ComputerObject(ComputerDetails details) {
|
public ComputerObject(ComputerDetails details) {
|
||||||
|
|||||||
@@ -8,17 +8,26 @@ import android.content.ServiceConnection;
|
|||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
|
|
||||||
|
import com.limelight.computers.ComputerDatabaseManager;
|
||||||
import com.limelight.computers.ComputerManagerListener;
|
import com.limelight.computers.ComputerManagerListener;
|
||||||
import com.limelight.computers.ComputerManagerService;
|
import com.limelight.computers.ComputerManagerService;
|
||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
import com.limelight.nvstream.http.NvApp;
|
import com.limelight.nvstream.http.NvApp;
|
||||||
|
import com.limelight.nvstream.http.NvHTTP;
|
||||||
import com.limelight.nvstream.http.PairingManager;
|
import com.limelight.nvstream.http.PairingManager;
|
||||||
|
import com.limelight.nvstream.wol.WakeOnLanSender;
|
||||||
|
import com.limelight.utils.CacheHelper;
|
||||||
import com.limelight.utils.Dialog;
|
import com.limelight.utils.Dialog;
|
||||||
import com.limelight.utils.ServerHelper;
|
import com.limelight.utils.ServerHelper;
|
||||||
import com.limelight.utils.SpinnerDialog;
|
import com.limelight.utils.SpinnerDialog;
|
||||||
import com.limelight.utils.UiHelper;
|
import com.limelight.utils.UiHelper;
|
||||||
|
|
||||||
|
import org.xmlpull.v1.XmlPullParserException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public class ShortcutTrampoline extends Activity {
|
public class ShortcutTrampoline extends Activity {
|
||||||
@@ -26,10 +35,12 @@ public class ShortcutTrampoline extends Activity {
|
|||||||
private NvApp app;
|
private NvApp app;
|
||||||
private ArrayList<Intent> intentStack = new ArrayList<>();
|
private ArrayList<Intent> intentStack = new ArrayList<>();
|
||||||
|
|
||||||
|
private int wakeHostTries = 10;
|
||||||
private ComputerDetails computer;
|
private ComputerDetails computer;
|
||||||
private SpinnerDialog blockingLoadSpinner;
|
private SpinnerDialog blockingLoadSpinner;
|
||||||
|
|
||||||
private ComputerManagerService.ComputerManagerBinder managerBinder;
|
private ComputerManagerService.ComputerManagerBinder managerBinder;
|
||||||
|
|
||||||
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
||||||
public void onServiceConnected(ComponentName className, IBinder binder) {
|
public void onServiceConnected(ComponentName className, IBinder binder) {
|
||||||
final ComputerManagerService.ComputerManagerBinder localBinder =
|
final ComputerManagerService.ComputerManagerBinder localBinder =
|
||||||
@@ -79,6 +90,23 @@ public class ShortcutTrampoline extends Activity {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to wake the target PC if it's offline (up to some retry limit)
|
||||||
|
if (details.state == ComputerDetails.State.OFFLINE && details.macAddress != null && --wakeHostTries >= 0) {
|
||||||
|
try {
|
||||||
|
// Make a best effort attempt to wake the target PC
|
||||||
|
WakeOnLanSender.sendWolPacket(computer);
|
||||||
|
|
||||||
|
// If we sent at least one WoL packet, reset the computer state
|
||||||
|
// to force ComputerManager to poll it again.
|
||||||
|
managerBinder.invalidateStateForComputer(computer.uuid);
|
||||||
|
return;
|
||||||
|
} catch (IOException e) {
|
||||||
|
// If we got an exception, we couldn't send a single WoL packet,
|
||||||
|
// so fallthrough into the offline error path.
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (details.state != ComputerDetails.State.UNKNOWN) {
|
if (details.state != ComputerDetails.State.UNKNOWN) {
|
||||||
runOnUiThread(new Runnable() {
|
runOnUiThread(new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
@@ -194,9 +222,9 @@ public class ShortcutTrampoline extends Activity {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
protected boolean validateInput(String uuidString, String appIdString) {
|
protected boolean validateInput(String uuidString, String appIdString, String nameString) {
|
||||||
// Validate UUID
|
// Validate PC UUID/Name
|
||||||
if (uuidString == null) {
|
if (uuidString == null && nameString == null) {
|
||||||
Dialog.displayDialog(ShortcutTrampoline.this,
|
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||||
getResources().getString(R.string.conn_error_title),
|
getResources().getString(R.string.conn_error_title),
|
||||||
getResources().getString(R.string.scut_invalid_uuid),
|
getResources().getString(R.string.scut_invalid_uuid),
|
||||||
@@ -204,14 +232,25 @@ public class ShortcutTrampoline extends Activity {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (uuidString != null && !uuidString.isEmpty()) {
|
||||||
UUID.fromString(uuidString);
|
try {
|
||||||
} catch (IllegalArgumentException ex) {
|
UUID.fromString(uuidString);
|
||||||
Dialog.displayDialog(ShortcutTrampoline.this,
|
} catch (IllegalArgumentException ex) {
|
||||||
getResources().getString(R.string.conn_error_title),
|
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||||
getResources().getString(R.string.scut_invalid_uuid),
|
getResources().getString(R.string.conn_error_title),
|
||||||
true);
|
getResources().getString(R.string.scut_invalid_uuid),
|
||||||
return false;
|
true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// UUID is null, so fallback to Name
|
||||||
|
if (nameString == null || nameString.isEmpty()) {
|
||||||
|
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||||
|
getResources().getString(R.string.conn_error_title),
|
||||||
|
getResources().getString(R.string.scut_invalid_uuid),
|
||||||
|
true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate App ID (if provided)
|
// Validate App ID (if provided)
|
||||||
@@ -235,24 +274,93 @@ public class ShortcutTrampoline extends Activity {
|
|||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
UiHelper.notifyNewRootView(this);
|
UiHelper.notifyNewRootView(this);
|
||||||
|
ComputerDatabaseManager dbManager = new ComputerDatabaseManager(this);
|
||||||
|
ComputerDetails _computer = null;
|
||||||
|
|
||||||
String appIdString = getIntent().getStringExtra(Game.EXTRA_APP_ID);
|
// PC arguments, both are optional, but at least one must be provided
|
||||||
uuidString = getIntent().getStringExtra(AppView.UUID_EXTRA);
|
uuidString = getIntent().getStringExtra(AppView.UUID_EXTRA);
|
||||||
|
String nameString = getIntent().getStringExtra(AppView.NAME_EXTRA);
|
||||||
|
|
||||||
if (validateInput(uuidString, appIdString)) {
|
// App arguments, both are optional, but one must be provided in order to start an app
|
||||||
if (appIdString != null && !appIdString.isEmpty()) {
|
String appIdString = getIntent().getStringExtra(Game.EXTRA_APP_ID);
|
||||||
app = new NvApp(getIntent().getStringExtra(Game.EXTRA_APP_NAME),
|
String appNameString = getIntent().getStringExtra(Game.EXTRA_APP_NAME);
|
||||||
Integer.parseInt(appIdString),
|
|
||||||
getIntent().getBooleanExtra(Game.EXTRA_APP_HDR, false));
|
if (!validateInput(uuidString, appIdString, nameString)) {
|
||||||
|
// Invalid input, so just return
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uuidString == null || uuidString.isEmpty()) {
|
||||||
|
// Use nameString to find the corresponding UUID
|
||||||
|
_computer = dbManager.getComputerByName(nameString);
|
||||||
|
|
||||||
|
if (_computer == null) {
|
||||||
|
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||||
|
getResources().getString(R.string.conn_error_title),
|
||||||
|
getResources().getString(R.string.scut_pc_not_found),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind to the computer manager service
|
uuidString = _computer.uuid;
|
||||||
bindService(new Intent(this, ComputerManagerService.class), serviceConnection,
|
|
||||||
Service.BIND_AUTO_CREATE);
|
|
||||||
|
|
||||||
blockingLoadSpinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title),
|
// Set the AppView UUID intent, since it wasn't provided
|
||||||
getResources().getString(R.string.applist_connect_msg), true);
|
setIntent(new Intent(getIntent()).putExtra(AppView.UUID_EXTRA, uuidString));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appIdString != null && !appIdString.isEmpty()) {
|
||||||
|
app = new NvApp(getIntent().getStringExtra(Game.EXTRA_APP_NAME),
|
||||||
|
Integer.parseInt(appIdString),
|
||||||
|
getIntent().getBooleanExtra(Game.EXTRA_APP_HDR, false));
|
||||||
|
}
|
||||||
|
else if (appNameString != null && !appNameString.isEmpty()) {
|
||||||
|
// Use appNameString to find the corresponding AppId
|
||||||
|
try {
|
||||||
|
int appId = -1;
|
||||||
|
String rawAppList = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(getCacheDir(), "applist", uuidString));
|
||||||
|
|
||||||
|
if (rawAppList.isEmpty()) {
|
||||||
|
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||||
|
getResources().getString(R.string.conn_error_title),
|
||||||
|
getResources().getString(R.string.scut_invalid_app_id),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<NvApp> applist = NvHTTP.getAppListByReader(new StringReader(rawAppList));
|
||||||
|
|
||||||
|
for (NvApp _app : applist) {
|
||||||
|
if (_app.getAppName().equals(appNameString)) {
|
||||||
|
appId = _app.getAppId();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (appId < 0) {
|
||||||
|
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||||
|
getResources().getString(R.string.conn_error_title),
|
||||||
|
getResources().getString(R.string.scut_invalid_app_id),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIntent(new Intent(getIntent()).putExtra(Game.EXTRA_APP_ID, appId));
|
||||||
|
app = new NvApp(
|
||||||
|
appNameString,
|
||||||
|
appId,
|
||||||
|
getIntent().getBooleanExtra(Game.EXTRA_APP_HDR, false));
|
||||||
|
} catch (IOException | XmlPullParserException e) {
|
||||||
|
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||||
|
getResources().getString(R.string.conn_error_title),
|
||||||
|
getResources().getString(R.string.scut_invalid_app_id),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind to the computer manager service
|
||||||
|
bindService(new Intent(this, ComputerManagerService.class), serviceConnection,
|
||||||
|
Service.BIND_AUTO_CREATE);
|
||||||
|
|
||||||
|
blockingLoadSpinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title),
|
||||||
|
getResources().getString(R.string.applist_connect_msg), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -8,16 +8,6 @@ import com.limelight.nvstream.av.audio.AudioRenderer;
|
|||||||
import com.limelight.nvstream.http.LimelightCryptoProvider;
|
import com.limelight.nvstream.http.LimelightCryptoProvider;
|
||||||
|
|
||||||
public class PlatformBinding {
|
public class PlatformBinding {
|
||||||
public static String getDeviceName() {
|
|
||||||
String deviceName = android.os.Build.MODEL;
|
|
||||||
deviceName = deviceName.replace(" ", "");
|
|
||||||
return deviceName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static AudioRenderer getAudioRenderer() {
|
|
||||||
return new AndroidAudioRenderer();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static LimelightCryptoProvider getCryptoProvider(Context c) {
|
public static LimelightCryptoProvider getCryptoProvider(Context c) {
|
||||||
return new AndroidCryptoProvider(c);
|
return new AndroidCryptoProvider(c);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.limelight.binding.audio;
|
package com.limelight.binding.audio;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
import android.media.AudioAttributes;
|
import android.media.AudioAttributes;
|
||||||
import android.media.AudioFormat;
|
import android.media.AudioFormat;
|
||||||
import android.media.AudioManager;
|
import android.media.AudioManager;
|
||||||
import android.media.AudioTrack;
|
import android.media.AudioTrack;
|
||||||
|
import android.media.audiofx.AudioEffect;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
@@ -12,54 +15,52 @@ import com.limelight.nvstream.jni.MoonBridge;
|
|||||||
|
|
||||||
public class AndroidAudioRenderer implements AudioRenderer {
|
public class AndroidAudioRenderer implements AudioRenderer {
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
private final boolean enableAudioFx;
|
||||||
|
|
||||||
private AudioTrack track;
|
private AudioTrack track;
|
||||||
|
|
||||||
|
public AndroidAudioRenderer(Context context, boolean enableAudioFx) {
|
||||||
|
this.context = context;
|
||||||
|
this.enableAudioFx = enableAudioFx;
|
||||||
|
}
|
||||||
|
|
||||||
private AudioTrack createAudioTrack(int channelConfig, int sampleRate, int bufferSize, boolean lowLatency) {
|
private AudioTrack createAudioTrack(int channelConfig, int sampleRate, int bufferSize, boolean lowLatency) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder()
|
||||||
return new AudioTrack(AudioManager.STREAM_MUSIC,
|
.setUsage(AudioAttributes.USAGE_GAME);
|
||||||
sampleRate,
|
AudioFormat format = new AudioFormat.Builder()
|
||||||
channelConfig,
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||||
AudioFormat.ENCODING_PCM_16BIT,
|
.setSampleRate(sampleRate)
|
||||||
bufferSize,
|
.setChannelMask(channelConfig)
|
||||||
AudioTrack.MODE_STREAM);
|
.build();
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
// Use FLAG_LOW_LATENCY on L through N
|
||||||
|
if (lowLatency) {
|
||||||
|
attributesBuilder.setFlags(AudioAttributes.FLAG_LOW_LATENCY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
AudioTrack.Builder trackBuilder = new AudioTrack.Builder()
|
||||||
|
.setAudioFormat(format)
|
||||||
|
.setAudioAttributes(attributesBuilder.build())
|
||||||
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
|
.setBufferSizeInBytes(bufferSize);
|
||||||
|
|
||||||
|
// Use PERFORMANCE_MODE_LOW_LATENCY on O and later
|
||||||
|
if (lowLatency) {
|
||||||
|
trackBuilder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackBuilder.build();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder()
|
return new AudioTrack(attributesBuilder.build(),
|
||||||
.setUsage(AudioAttributes.USAGE_GAME);
|
format,
|
||||||
AudioFormat format = new AudioFormat.Builder()
|
bufferSize,
|
||||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
AudioTrack.MODE_STREAM,
|
||||||
.setSampleRate(sampleRate)
|
AudioManager.AUDIO_SESSION_ID_GENERATE);
|
||||||
.setChannelMask(channelConfig)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
||||||
// Use FLAG_LOW_LATENCY on L through N
|
|
||||||
if (lowLatency) {
|
|
||||||
attributesBuilder.setFlags(AudioAttributes.FLAG_LOW_LATENCY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
AudioTrack.Builder trackBuilder = new AudioTrack.Builder()
|
|
||||||
.setAudioFormat(format)
|
|
||||||
.setAudioAttributes(attributesBuilder.build())
|
|
||||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
|
||||||
.setBufferSizeInBytes(bufferSize);
|
|
||||||
|
|
||||||
// Use PERFORMANCE_MODE_LOW_LATENCY on O and later
|
|
||||||
if (lowLatency) {
|
|
||||||
trackBuilder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY);
|
|
||||||
}
|
|
||||||
|
|
||||||
return trackBuilder.build();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return new AudioTrack(attributesBuilder.build(),
|
|
||||||
format,
|
|
||||||
bufferSize,
|
|
||||||
AudioTrack.MODE_STREAM,
|
|
||||||
AudioManager.AUDIO_SESSION_ID_GENERATE);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,20 +81,10 @@ public class AndroidAudioRenderer implements AudioRenderer {
|
|||||||
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
|
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
|
||||||
break;
|
break;
|
||||||
case 8:
|
case 8:
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
// AudioFormat.CHANNEL_OUT_7POINT1_SURROUND isn't available until Android 6.0,
|
||||||
// AudioFormat.CHANNEL_OUT_7POINT1_SURROUND isn't available until Android 6.0,
|
// yet the CHANNEL_OUT_SIDE_LEFT and CHANNEL_OUT_SIDE_RIGHT constants were added
|
||||||
// yet the CHANNEL_OUT_SIDE_LEFT and CHANNEL_OUT_SIDE_RIGHT constants were added
|
// in 5.0, so just hardcode the constant so we can work on Lollipop.
|
||||||
// in 5.0, so just hardcode the constant so we can work on Lollipop.
|
channelConfig = 0x000018fc; // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
|
||||||
channelConfig = 0x000018fc; // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// On KitKat and lower, creation of the AudioTrack will fail if we specify
|
|
||||||
// CHANNEL_OUT_SIDE_LEFT or CHANNEL_OUT_SIDE_RIGHT. That leaves us with
|
|
||||||
// the old CHANNEL_OUT_7POINT1 which uses left-of-center and right-of-center
|
|
||||||
// speakers instead of side-left and side-right. This non-standard layout
|
|
||||||
// is probably not what the user wants, but we don't really have a choice.
|
|
||||||
channelConfig = AudioFormat.CHANNEL_OUT_7POINT1;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
LimeLog.severe("Decoder returned unhandled channel count");
|
LimeLog.severe("Decoder returned unhandled channel count");
|
||||||
@@ -161,6 +152,12 @@ public class AndroidAudioRenderer implements AudioRenderer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip low latency options when using audio effects, since low latency mode
|
||||||
|
// precludes the use of the audio effect pipeline (as of Android 13).
|
||||||
|
if (enableAudioFx && lowLatency) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
track = createAudioTrack(channelConfig, sampleRate, bufferSize, lowLatency);
|
track = createAudioTrack(channelConfig, sampleRate, bufferSize, lowLatency);
|
||||||
track.play();
|
track.play();
|
||||||
@@ -203,10 +200,27 @@ public class AndroidAudioRenderer implements AudioRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start() {}
|
public void start() {
|
||||||
|
if (enableAudioFx) {
|
||||||
|
// Open an audio effect control session to allow equalizers to apply audio effects
|
||||||
|
Intent i = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION);
|
||||||
|
i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId());
|
||||||
|
i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName());
|
||||||
|
i.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_GAME);
|
||||||
|
context.sendBroadcast(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void stop() {}
|
public void stop() {
|
||||||
|
if (enableAudioFx) {
|
||||||
|
// Close our audio effect control session when we're stopping
|
||||||
|
Intent i = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION);
|
||||||
|
i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId());
|
||||||
|
i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName());
|
||||||
|
context.sendBroadcast(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void cleanup() {
|
public void cleanup() {
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import java.security.KeyFactory;
|
|||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.PrivateKey;
|
||||||
import java.security.Provider;
|
import java.security.Provider;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.security.cert.CertificateException;
|
import java.security.cert.CertificateException;
|
||||||
import java.security.cert.CertificateFactory;
|
import java.security.cert.CertificateFactory;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.security.interfaces.RSAPrivateKey;
|
|
||||||
import java.security.spec.InvalidKeySpecException;
|
import java.security.spec.InvalidKeySpecException;
|
||||||
import java.security.spec.PKCS8EncodedKeySpec;
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
import java.util.Calendar;
|
import java.util.Calendar;
|
||||||
@@ -48,7 +48,7 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
|||||||
private final File keyFile;
|
private final File keyFile;
|
||||||
|
|
||||||
private X509Certificate cert;
|
private X509Certificate cert;
|
||||||
private RSAPrivateKey key;
|
private PrivateKey key;
|
||||||
private byte[] pemCertBytes;
|
private byte[] pemCertBytes;
|
||||||
|
|
||||||
private static final Object globalCryptoLock = new Object();
|
private static final Object globalCryptoLock = new Object();
|
||||||
@@ -67,14 +67,12 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try (final FileInputStream fin = new FileInputStream(f)) {
|
||||||
FileInputStream fin = new FileInputStream(f);
|
|
||||||
byte[] fileData = new byte[(int) f.length()];
|
byte[] fileData = new byte[(int) f.length()];
|
||||||
if (fin.read(fileData) != f.length()) {
|
if (fin.read(fileData) != f.length()) {
|
||||||
// Failed to read
|
// Failed to read
|
||||||
fileData = null;
|
fileData = null;
|
||||||
}
|
}
|
||||||
fin.close();
|
|
||||||
return fileData;
|
return fileData;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return null;
|
return null;
|
||||||
@@ -96,15 +94,13 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
|||||||
cert = (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes));
|
cert = (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||||
pemCertBytes = certBytes;
|
pemCertBytes = certBytes;
|
||||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA", bcProvider);
|
KeyFactory keyFactory = KeyFactory.getInstance("RSA", bcProvider);
|
||||||
key = (RSAPrivateKey) keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
key = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
||||||
} catch (CertificateException e) {
|
} catch (CertificateException e) {
|
||||||
// May happen if the cert is corrupt
|
// May happen if the cert is corrupt
|
||||||
LimeLog.warning("Corrupted certificate");
|
LimeLog.warning("Corrupted certificate");
|
||||||
return false;
|
return false;
|
||||||
} catch (NoSuchAlgorithmException e) {
|
} catch (NoSuchAlgorithmException e) {
|
||||||
// Should never happen
|
throw new RuntimeException(e);
|
||||||
e.printStackTrace();
|
|
||||||
return false;
|
|
||||||
} catch (InvalidKeySpecException e) {
|
} catch (InvalidKeySpecException e) {
|
||||||
// May happen if the key is corrupt
|
// May happen if the key is corrupt
|
||||||
LimeLog.warning("Corrupted key");
|
LimeLog.warning("Corrupted key");
|
||||||
@@ -124,10 +120,8 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
|||||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", bcProvider);
|
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", bcProvider);
|
||||||
keyPairGenerator.initialize(2048);
|
keyPairGenerator.initialize(2048);
|
||||||
keyPair = keyPairGenerator.generateKeyPair();
|
keyPair = keyPairGenerator.generateKeyPair();
|
||||||
} catch (NoSuchAlgorithmException e1) {
|
} catch (NoSuchAlgorithmException e) {
|
||||||
// Should never happen
|
throw new RuntimeException(e);
|
||||||
e1.printStackTrace();
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
@@ -150,10 +144,8 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
|||||||
try {
|
try {
|
||||||
ContentSigner sigGen = new JcaContentSignerBuilder("SHA256withRSA").setProvider(bcProvider).build(keyPair.getPrivate());
|
ContentSigner sigGen = new JcaContentSignerBuilder("SHA256withRSA").setProvider(bcProvider).build(keyPair.getPrivate());
|
||||||
cert = new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate(certBuilder.build(sigGen));
|
cert = new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate(certBuilder.build(sigGen));
|
||||||
key = (RSAPrivateKey) keyPair.getPrivate();
|
key = keyPair.getPrivate();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Nothing should go wrong here
|
|
||||||
e.printStackTrace();
|
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,32 +158,28 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void saveCertKeyPair() {
|
private void saveCertKeyPair() {
|
||||||
try {
|
try (final FileOutputStream certOut = new FileOutputStream(certFile);
|
||||||
FileOutputStream certOut = new FileOutputStream(certFile);
|
final FileOutputStream keyOut = new FileOutputStream(keyFile)
|
||||||
FileOutputStream keyOut = new FileOutputStream(keyFile);
|
) {
|
||||||
|
|
||||||
// Write the certificate in OpenSSL PEM format (important for the server)
|
// Write the certificate in OpenSSL PEM format (important for the server)
|
||||||
StringWriter strWriter = new StringWriter();
|
StringWriter strWriter = new StringWriter();
|
||||||
JcaPEMWriter pemWriter = new JcaPEMWriter(strWriter);
|
try (final JcaPEMWriter pemWriter = new JcaPEMWriter(strWriter)) {
|
||||||
pemWriter.writeObject(cert);
|
pemWriter.writeObject(cert);
|
||||||
pemWriter.close();
|
}
|
||||||
|
|
||||||
// Line endings MUST be UNIX for the PC to accept the cert properly
|
// Line endings MUST be UNIX for the PC to accept the cert properly
|
||||||
OutputStreamWriter certWriter = new OutputStreamWriter(certOut);
|
try (final OutputStreamWriter certWriter = new OutputStreamWriter(certOut)) {
|
||||||
String pemStr = strWriter.getBuffer().toString();
|
String pemStr = strWriter.getBuffer().toString();
|
||||||
for (int i = 0; i < pemStr.length(); i++) {
|
for (int i = 0; i < pemStr.length(); i++) {
|
||||||
char c = pemStr.charAt(i);
|
char c = pemStr.charAt(i);
|
||||||
if (c != '\r')
|
if (c != '\r')
|
||||||
certWriter.append(c);
|
certWriter.append(c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
certWriter.close();
|
|
||||||
|
|
||||||
// Write the private out in PKCS8 format
|
// Write the private out in PKCS8 format
|
||||||
keyOut.write(key.getEncoded());
|
keyOut.write(key.getEncoded());
|
||||||
|
|
||||||
certOut.close();
|
|
||||||
keyOut.close();
|
|
||||||
|
|
||||||
LimeLog.info("Saved generated key pair to disk");
|
LimeLog.info("Saved generated key pair to disk");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// This isn't good because it means we'll have
|
// This isn't good because it means we'll have
|
||||||
@@ -227,7 +215,7 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public RSAPrivateKey getClientPrivateKey() {
|
public PrivateKey getClientPrivateKey() {
|
||||||
// Use a lock here to ensure only one guy will be generating or loading
|
// Use a lock here to ensure only one guy will be generating or loading
|
||||||
// the certificate and key at a time
|
// the certificate and key at a time
|
||||||
synchronized (globalCryptoLock) {
|
synchronized (globalCryptoLock) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,20 @@
|
|||||||
package com.limelight.binding.input;
|
package com.limelight.binding.input;
|
||||||
|
|
||||||
|
import android.annotation.TargetApi;
|
||||||
|
import android.hardware.input.InputManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.SparseArray;
|
||||||
|
import android.view.InputDevice;
|
||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class to translate a Android key code into the codes GFE is expecting
|
* Class to translate a Android key code into the codes GFE is expecting
|
||||||
* @author Diego Waxemberg
|
* @author Diego Waxemberg
|
||||||
* @author Cameron Gutman
|
* @author Cameron Gutman
|
||||||
*/
|
*/
|
||||||
public class KeyboardTranslator {
|
public class KeyboardTranslator implements InputManager.InputDeviceListener {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GFE's prefix for every key code
|
* GFE's prefix for every key code
|
||||||
@@ -48,27 +55,92 @@ public class KeyboardTranslator {
|
|||||||
public static final int VK_QUOTE = 222;
|
public static final int VK_QUOTE = 222;
|
||||||
public static final int VK_PAUSE = 19;
|
public static final int VK_PAUSE = 19;
|
||||||
|
|
||||||
public static boolean needsShift(int keycode) {
|
private static class KeyboardMapping {
|
||||||
switch (keycode)
|
private final InputDevice device;
|
||||||
{
|
private final int[] deviceKeyCodeToQwertyKeyCode;
|
||||||
case KeyEvent.KEYCODE_AT:
|
|
||||||
case KeyEvent.KEYCODE_POUND:
|
|
||||||
case KeyEvent.KEYCODE_PLUS:
|
|
||||||
case KeyEvent.KEYCODE_STAR:
|
|
||||||
return true;
|
|
||||||
|
|
||||||
default:
|
@TargetApi(33)
|
||||||
return false;
|
public KeyboardMapping(InputDevice device) {
|
||||||
|
int maxKeyCode = KeyEvent.getMaxKeyCode();
|
||||||
|
|
||||||
|
this.device = device;
|
||||||
|
this.deviceKeyCodeToQwertyKeyCode = new int[maxKeyCode + 1];
|
||||||
|
|
||||||
|
// Any unmatched keycodes are treated as unknown
|
||||||
|
Arrays.fill(deviceKeyCodeToQwertyKeyCode, KeyEvent.KEYCODE_UNKNOWN);
|
||||||
|
|
||||||
|
for (int i = 0; i <= maxKeyCode; i++) {
|
||||||
|
int deviceKeyCode = device.getKeyCodeForKeyLocation(i);
|
||||||
|
if (deviceKeyCode != KeyEvent.KEYCODE_UNKNOWN) {
|
||||||
|
deviceKeyCodeToQwertyKeyCode[deviceKeyCode] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TargetApi(33)
|
||||||
|
public int getDeviceKeyCodeForQwertyKeyCode(int qwertyKeyCode) {
|
||||||
|
return device.getKeyCodeForKeyLocation(qwertyKeyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getQwertyKeyCodeForDeviceKeyCode(int deviceKeyCode) {
|
||||||
|
if (deviceKeyCode > KeyEvent.getMaxKeyCode()) {
|
||||||
|
return KeyEvent.KEYCODE_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceKeyCodeToQwertyKeyCode[deviceKeyCode];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final SparseArray<KeyboardMapping> keyboardMappings = new SparseArray<>();
|
||||||
|
|
||||||
|
public KeyboardTranslator() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
for (int deviceId : InputDevice.getDeviceIds()) {
|
||||||
|
InputDevice device = InputDevice.getDevice(deviceId);
|
||||||
|
if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
|
||||||
|
keyboardMappings.set(deviceId, new KeyboardMapping(device));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasNormalizedMapping(int keycode, int deviceId) {
|
||||||
|
if (deviceId >= 0) {
|
||||||
|
KeyboardMapping mapping = keyboardMappings.get(deviceId);
|
||||||
|
if (mapping != null) {
|
||||||
|
// Try to map this device-specific keycode onto a QWERTY layout.
|
||||||
|
// GFE assumes incoming keycodes are from a QWERTY keyboard.
|
||||||
|
int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode);
|
||||||
|
if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates the given keycode and returns the GFE keycode
|
* Translates the given keycode and returns the GFE keycode
|
||||||
* @param keycode the code to be translated
|
* @param keycode the code to be translated
|
||||||
|
* @param deviceId InputDevice.getId() or -1 if unknown
|
||||||
* @return a GFE keycode for the given keycode
|
* @return a GFE keycode for the given keycode
|
||||||
*/
|
*/
|
||||||
public static short translate(int keycode) {
|
public short translate(int keycode, int deviceId) {
|
||||||
int translated;
|
int translated;
|
||||||
|
|
||||||
|
// If a device ID was provided, look up the keyboard mapping
|
||||||
|
if (deviceId >= 0) {
|
||||||
|
KeyboardMapping mapping = keyboardMappings.get(deviceId);
|
||||||
|
if (mapping != null) {
|
||||||
|
// Try to map this device-specific keycode onto a QWERTY layout.
|
||||||
|
// GFE assumes incoming keycodes are from a QWERTY keyboard.
|
||||||
|
int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode);
|
||||||
|
if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) {
|
||||||
|
keycode = qwertyKeyCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// This is a poor man's mapping between Android key codes
|
// This is a poor man's mapping between Android key codes
|
||||||
// and Windows VK_* codes. For all defined VK_ codes, see:
|
// and Windows VK_* codes. For all defined VK_ codes, see:
|
||||||
@@ -160,6 +232,10 @@ public class KeyboardTranslator {
|
|||||||
translated = 0x5c;
|
translated = 0x5c;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case KeyEvent.KEYCODE_MENU:
|
||||||
|
translated = 0x5d;
|
||||||
|
break;
|
||||||
|
|
||||||
case KeyEvent.KEYCODE_MINUS:
|
case KeyEvent.KEYCODE_MINUS:
|
||||||
translated = 0xbd;
|
translated = 0xbd;
|
||||||
break;
|
break;
|
||||||
@@ -273,20 +349,7 @@ public class KeyboardTranslator {
|
|||||||
translated = 0x6E;
|
translated = 0x6E;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case KeyEvent.KEYCODE_AT:
|
|
||||||
translated = 2 + VK_0;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case KeyEvent.KEYCODE_POUND:
|
|
||||||
translated = 3 + VK_0;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case KeyEvent.KEYCODE_STAR:
|
|
||||||
translated = 8 + VK_0;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
System.out.println("No key for "+keycode);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,4 +357,30 @@ public class KeyboardTranslator {
|
|||||||
return (short) ((KEY_PREFIX << 8) | translated);
|
return (short) ((KEY_PREFIX << 8) | translated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInputDeviceAdded(int index) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
InputDevice device = InputDevice.getDevice(index);
|
||||||
|
if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
|
||||||
|
keyboardMappings.put(index, new KeyboardMapping(device));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInputDeviceRemoved(int index) {
|
||||||
|
keyboardMappings.remove(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInputDeviceChanged(int index) {
|
||||||
|
keyboardMappings.remove(index);
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
InputDevice device = InputDevice.getDevice(index);
|
||||||
|
if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
|
||||||
|
keyboardMappings.set(index, new KeyboardMapping(device));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-13
@@ -2,7 +2,9 @@ package com.limelight.binding.input.capture;
|
|||||||
|
|
||||||
import android.annotation.TargetApi;
|
import android.annotation.TargetApi;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
import android.hardware.input.InputManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
import android.view.InputDevice;
|
import android.view.InputDevice;
|
||||||
import android.view.MotionEvent;
|
import android.view.MotionEvent;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
@@ -12,11 +14,13 @@ import android.view.View;
|
|||||||
// pointer icon hiding behavior over our stream view just in case pointer capture
|
// pointer icon hiding behavior over our stream view just in case pointer capture
|
||||||
// is unavailable on this system (ex: DeX, ChromeOS)
|
// is unavailable on this system (ex: DeX, ChromeOS)
|
||||||
@TargetApi(Build.VERSION_CODES.O)
|
@TargetApi(Build.VERSION_CODES.O)
|
||||||
public class AndroidNativePointerCaptureProvider extends AndroidPointerIconCaptureProvider {
|
public class AndroidNativePointerCaptureProvider extends AndroidPointerIconCaptureProvider implements InputManager.InputDeviceListener {
|
||||||
private View targetView;
|
private final InputManager inputManager;
|
||||||
|
private final View targetView;
|
||||||
|
|
||||||
public AndroidNativePointerCaptureProvider(Activity activity, View targetView) {
|
public AndroidNativePointerCaptureProvider(Activity activity, View targetView) {
|
||||||
super(activity, targetView);
|
super(activity, targetView);
|
||||||
|
this.inputManager = activity.getSystemService(InputManager.class);
|
||||||
this.targetView = targetView;
|
this.targetView = targetView;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,38 +28,143 @@ public class AndroidNativePointerCaptureProvider extends AndroidPointerIconCaptu
|
|||||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
// We only capture the pointer if we have a compatible InputDevice
|
||||||
public void enableCapture() {
|
// present. This is a workaround for an Android 12 regression causing
|
||||||
super.enableCapture();
|
// incorrect mouse input when using the SPen.
|
||||||
targetView.requestPointerCapture();
|
// https://github.com/moonlight-stream/moonlight-android/issues/1030
|
||||||
|
private boolean hasCaptureCompatibleInputDevice() {
|
||||||
|
for (int id : InputDevice.getDeviceIds()) {
|
||||||
|
InputDevice device = InputDevice.getDevice(id);
|
||||||
|
if (device == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip touchscreens when considering compatible capture devices.
|
||||||
|
// Samsung devices on Android 12 will report a sec_touchpad device
|
||||||
|
// with SOURCE_TOUCHSCREEN, SOURCE_KEYBOARD, and SOURCE_MOUSE.
|
||||||
|
// Upon enabling pointer capture, that device will switch to
|
||||||
|
// SOURCE_KEYBOARD and SOURCE_TOUCHPAD.
|
||||||
|
// Only skip on non ChromeOS devices cause the ChromeOS pointer else
|
||||||
|
// gets disabled removing relative mouse capabilities
|
||||||
|
// on Chromebooks with touchscreens
|
||||||
|
if (device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN) && !targetView.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.supportsSource(InputDevice.SOURCE_MOUSE) ||
|
||||||
|
device.supportsSource(InputDevice.SOURCE_MOUSE_RELATIVE) ||
|
||||||
|
device.supportsSource(InputDevice.SOURCE_TOUCHPAD)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void disableCapture() {
|
public void showCursor() {
|
||||||
super.disableCapture();
|
super.showCursor();
|
||||||
|
|
||||||
|
// It is important to unregister the listener *before* releasing pointer capture,
|
||||||
|
// because releasing pointer capture can cause an onInputDeviceChanged() callback
|
||||||
|
// for devices with a touchpad (like a DS4 controller).
|
||||||
|
inputManager.unregisterInputDeviceListener(this);
|
||||||
targetView.releasePointerCapture();
|
targetView.releasePointerCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void hideCursor() {
|
||||||
|
super.hideCursor();
|
||||||
|
|
||||||
|
// Listen for device events to enable/disable capture
|
||||||
|
inputManager.registerInputDeviceListener(this, null);
|
||||||
|
|
||||||
|
// Capture now if we have a capture-capable device
|
||||||
|
if (hasCaptureCompatibleInputDevice()) {
|
||||||
|
targetView.requestPointerCapture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onWindowFocusChanged(boolean focusActive) {
|
||||||
|
// NB: We have to check cursor visibility here because Android pointer capture
|
||||||
|
// doesn't support capturing the cursor while it's visible. Enabling pointer
|
||||||
|
// capture implicitly hides the cursor.
|
||||||
|
if (!focusActive || !isCapturing || isCursorVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recapture the pointer if focus was regained. On Android Q,
|
||||||
|
// we have to delay a bit before requesting capture because otherwise
|
||||||
|
// we'll hit the "requestPointerCapture called for a window that has no focus"
|
||||||
|
// error and it will not actually capture the cursor.
|
||||||
|
Handler h = new Handler();
|
||||||
|
h.postDelayed(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (hasCaptureCompatibleInputDevice()) {
|
||||||
|
targetView.requestPointerCapture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
||||||
return event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE;
|
// SOURCE_MOUSE_RELATIVE is how SOURCE_MOUSE appears when our view has pointer capture.
|
||||||
|
// SOURCE_TOUCHPAD will have relative axes populated iff our view has pointer capture.
|
||||||
|
// See https://developer.android.com/reference/android/view/View#requestPointerCapture()
|
||||||
|
int eventSource = event.getSource();
|
||||||
|
return (eventSource == InputDevice.SOURCE_MOUSE_RELATIVE && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) ||
|
||||||
|
(eventSource == InputDevice.SOURCE_TOUCHPAD && targetView.hasPointerCapture());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public float getRelativeAxisX(MotionEvent event) {
|
public float getRelativeAxisX(MotionEvent event) {
|
||||||
float x = event.getX();
|
int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ?
|
||||||
|
MotionEvent.AXIS_X : MotionEvent.AXIS_RELATIVE_X;
|
||||||
|
float x = event.getAxisValue(axis);
|
||||||
for (int i = 0; i < event.getHistorySize(); i++) {
|
for (int i = 0; i < event.getHistorySize(); i++) {
|
||||||
x += event.getHistoricalX(i);
|
x += event.getHistoricalAxisValue(axis, i);
|
||||||
}
|
}
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public float getRelativeAxisY(MotionEvent event) {
|
public float getRelativeAxisY(MotionEvent event) {
|
||||||
float y = event.getY();
|
int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ?
|
||||||
|
MotionEvent.AXIS_Y : MotionEvent.AXIS_RELATIVE_Y;
|
||||||
|
float y = event.getAxisValue(axis);
|
||||||
for (int i = 0; i < event.getHistorySize(); i++) {
|
for (int i = 0; i < event.getHistorySize(); i++) {
|
||||||
y += event.getHistoricalY(i);
|
y += event.getHistoricalAxisValue(axis, i);
|
||||||
}
|
}
|
||||||
return y;
|
return y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInputDeviceAdded(int deviceId) {
|
||||||
|
// Check if we've added a capture-compatible device
|
||||||
|
if (!targetView.hasPointerCapture() && hasCaptureCompatibleInputDevice()) {
|
||||||
|
targetView.requestPointerCapture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInputDeviceRemoved(int deviceId) {
|
||||||
|
// Check if the capture-compatible device was removed
|
||||||
|
if (targetView.hasPointerCapture() && !hasCaptureCompatibleInputDevice()) {
|
||||||
|
targetView.releasePointerCapture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInputDeviceChanged(int deviceId) {
|
||||||
|
// Emulating a remove+add should be sufficient for our purposes.
|
||||||
|
//
|
||||||
|
// Note: This callback must be handled carefully because it can happen as a result of
|
||||||
|
// calling requestPointerCapture(). This can cause trackpad devices to gain SOURCE_MOUSE_RELATIVE
|
||||||
|
// and re-enter this callback.
|
||||||
|
onInputDeviceRemoved(deviceId);
|
||||||
|
onInputDeviceAdded(deviceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-7
@@ -4,14 +4,13 @@ import android.annotation.TargetApi;
|
|||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.view.MotionEvent;
|
|
||||||
import android.view.PointerIcon;
|
import android.view.PointerIcon;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.N)
|
@TargetApi(Build.VERSION_CODES.N)
|
||||||
public class AndroidPointerIconCaptureProvider extends InputCaptureProvider {
|
public class AndroidPointerIconCaptureProvider extends InputCaptureProvider {
|
||||||
private View targetView;
|
private final View targetView;
|
||||||
private Context context;
|
private final Context context;
|
||||||
|
|
||||||
public AndroidPointerIconCaptureProvider(Activity activity, View targetView) {
|
public AndroidPointerIconCaptureProvider(Activity activity, View targetView) {
|
||||||
this.context = activity;
|
this.context = activity;
|
||||||
@@ -23,14 +22,14 @@ public class AndroidPointerIconCaptureProvider extends InputCaptureProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void enableCapture() {
|
public void hideCursor() {
|
||||||
super.enableCapture();
|
super.hideCursor();
|
||||||
targetView.setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL));
|
targetView.setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void disableCapture() {
|
public void showCursor() {
|
||||||
super.disableCapture();
|
super.showCursor();
|
||||||
targetView.setPointerIcon(null);
|
targetView.setPointerIcon(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package com.limelight.binding.input.capture;
|
|||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
|
||||||
|
import com.limelight.BuildConfig;
|
||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
import com.limelight.LimelightBuildProps;
|
|
||||||
import com.limelight.R;
|
import com.limelight.R;
|
||||||
import com.limelight.binding.input.evdev.EvdevCaptureProviderShim;
|
import com.limelight.binding.input.evdev.EvdevCaptureProviderShim;
|
||||||
import com.limelight.binding.input.evdev.EvdevListener;
|
import com.limelight.binding.input.evdev.EvdevListener;
|
||||||
@@ -16,7 +16,7 @@ public class InputCaptureManager {
|
|||||||
}
|
}
|
||||||
// LineageOS implemented broken NVIDIA capture extensions, so avoid using them on root builds.
|
// LineageOS implemented broken NVIDIA capture extensions, so avoid using them on root builds.
|
||||||
// See https://github.com/LineageOS/android_frameworks_base/commit/d304f478a023430f4712dbdc3ee69d9ad02cebd3
|
// See https://github.com/LineageOS/android_frameworks_base/commit/d304f478a023430f4712dbdc3ee69d9ad02cebd3
|
||||||
else if (!LimelightBuildProps.ROOT_BUILD && ShieldCaptureProvider.isCaptureProviderSupported()) {
|
else if (!BuildConfig.ROOT_BUILD && ShieldCaptureProvider.isCaptureProviderSupported()) {
|
||||||
LimeLog.info("Using NVIDIA mouse capture extension");
|
LimeLog.info("Using NVIDIA mouse capture extension");
|
||||||
return new ShieldCaptureProvider(activity);
|
return new ShieldCaptureProvider(activity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import android.view.MotionEvent;
|
|||||||
|
|
||||||
public abstract class InputCaptureProvider {
|
public abstract class InputCaptureProvider {
|
||||||
protected boolean isCapturing;
|
protected boolean isCapturing;
|
||||||
|
protected boolean isCursorVisible;
|
||||||
|
|
||||||
public void enableCapture() {
|
public void enableCapture() {
|
||||||
isCapturing = true;
|
isCapturing = true;
|
||||||
|
hideCursor();
|
||||||
}
|
}
|
||||||
public void disableCapture() {
|
public void disableCapture() {
|
||||||
isCapturing = false;
|
isCapturing = false;
|
||||||
|
showCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void destroy() {}
|
public void destroy() {}
|
||||||
@@ -22,6 +25,14 @@ public abstract class InputCaptureProvider {
|
|||||||
return isCapturing;
|
return isCapturing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void showCursor() {
|
||||||
|
isCursorVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void hideCursor() {
|
||||||
|
isCursorVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -33,4 +44,6 @@ public abstract class InputCaptureProvider {
|
|||||||
public float getRelativeAxisY(MotionEvent event) {
|
public float getRelativeAxisY(MotionEvent event) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void onWindowFocusChanged(boolean focusActive) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class ShieldCaptureProvider extends InputCaptureProvider {
|
|||||||
private static int AXIS_RELATIVE_X;
|
private static int AXIS_RELATIVE_X;
|
||||||
private static int AXIS_RELATIVE_Y;
|
private static int AXIS_RELATIVE_Y;
|
||||||
|
|
||||||
private Context context;
|
private final Context context;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
try {
|
try {
|
||||||
@@ -62,14 +62,14 @@ public class ShieldCaptureProvider extends InputCaptureProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void enableCapture() {
|
public void hideCursor() {
|
||||||
super.enableCapture();
|
super.hideCursor();
|
||||||
setCursorVisibility(false);
|
setCursorVisibility(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void disableCapture() {
|
public void showCursor() {
|
||||||
super.disableCapture();
|
super.showCursor();
|
||||||
setCursorVisibility(true);
|
setCursorVisibility(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ public abstract class AbstractController {
|
|||||||
|
|
||||||
private UsbDriverListener listener;
|
private UsbDriverListener listener;
|
||||||
|
|
||||||
protected short buttonFlags;
|
protected int buttonFlags, supportedButtonFlags;
|
||||||
protected float leftTrigger, rightTrigger;
|
protected float leftTrigger, rightTrigger;
|
||||||
protected float rightStickX, rightStickY;
|
protected float rightStickX, rightStickY;
|
||||||
protected float leftStickX, leftStickY;
|
protected float leftStickX, leftStickY;
|
||||||
|
protected short capabilities;
|
||||||
|
protected byte type;
|
||||||
|
|
||||||
public int getControllerId() {
|
public int getControllerId() {
|
||||||
return deviceId;
|
return deviceId;
|
||||||
@@ -25,6 +27,18 @@ public abstract class AbstractController {
|
|||||||
return productId;
|
return productId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getSupportedButtonFlags() {
|
||||||
|
return supportedButtonFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public short getCapabilities() {
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
protected void setButtonFlag(int buttonFlag, int data) {
|
protected void setButtonFlag(int buttonFlag, int data) {
|
||||||
if (data != 0) {
|
if (data != 0) {
|
||||||
buttonFlags |= buttonFlag;
|
buttonFlags |= buttonFlag;
|
||||||
@@ -51,6 +65,8 @@ public abstract class AbstractController {
|
|||||||
|
|
||||||
public abstract void rumble(short lowFreqMotor, short highFreqMotor);
|
public abstract void rumble(short lowFreqMotor, short highFreqMotor);
|
||||||
|
|
||||||
|
public abstract void rumbleTriggers(short leftTrigger, short rightTrigger);
|
||||||
|
|
||||||
protected void notifyDeviceRemoved() {
|
protected void notifyDeviceRemoved() {
|
||||||
listener.deviceRemoved(this);
|
listener.deviceRemoved(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import android.hardware.usb.UsbDevice;
|
|||||||
import android.hardware.usb.UsbDeviceConnection;
|
import android.hardware.usb.UsbDeviceConnection;
|
||||||
import android.hardware.usb.UsbEndpoint;
|
import android.hardware.usb.UsbEndpoint;
|
||||||
import android.hardware.usb.UsbInterface;
|
import android.hardware.usb.UsbInterface;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
|
||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
import com.limelight.binding.video.MediaCodecHelper;
|
import com.limelight.nvstream.input.ControllerPacket;
|
||||||
|
import com.limelight.nvstream.jni.MoonBridge;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
@@ -25,6 +27,14 @@ public abstract class AbstractXboxController extends AbstractController {
|
|||||||
super(deviceId, listener, device.getVendorId(), device.getProductId());
|
super(deviceId, listener, device.getVendorId(), device.getProductId());
|
||||||
this.device = device;
|
this.device = device;
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
|
this.type = MoonBridge.LI_CTYPE_XBOX;
|
||||||
|
this.capabilities = MoonBridge.LI_CCAP_ANALOG_TRIGGERS | MoonBridge.LI_CCAP_RUMBLE;
|
||||||
|
this.buttonFlags =
|
||||||
|
ControllerPacket.A_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.Y_FLAG |
|
||||||
|
ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG |
|
||||||
|
ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG |
|
||||||
|
ControllerPacket.LS_CLK_FLAG | ControllerPacket.RS_CLK_FLAG |
|
||||||
|
ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Thread createInputThread() {
|
private Thread createInputThread() {
|
||||||
@@ -37,7 +47,9 @@ public abstract class AbstractXboxController extends AbstractController {
|
|||||||
// around when we call notifyDeviceAdded(), we won't be able to claim
|
// around when we call notifyDeviceAdded(), we won't be able to claim
|
||||||
// the controller number used by the original InputDevice.
|
// the controller number used by the original InputDevice.
|
||||||
Thread.sleep(1000);
|
Thread.sleep(1000);
|
||||||
} catch (InterruptedException e) {}
|
} catch (InterruptedException e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Report that we're added _before_ reporting input
|
// Report that we're added _before_ reporting input
|
||||||
notifyDeviceAdded();
|
notifyDeviceAdded();
|
||||||
@@ -56,7 +68,7 @@ public abstract class AbstractXboxController extends AbstractController {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
// Read the next input state packet
|
// Read the next input state packet
|
||||||
long lastMillis = MediaCodecHelper.getMonotonicMillis();
|
long lastMillis = SystemClock.uptimeMillis();
|
||||||
res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 3000);
|
res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 3000);
|
||||||
|
|
||||||
// If we get a zero length response, treat it as an error
|
// If we get a zero length response, treat it as an error
|
||||||
@@ -64,7 +76,7 @@ public abstract class AbstractXboxController extends AbstractController {
|
|||||||
res = -1;
|
res = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res == -1 && MediaCodecHelper.getMonotonicMillis() - lastMillis < 1000) {
|
if (res == -1 && SystemClock.uptimeMillis() - lastMillis < 1000) {
|
||||||
LimeLog.warning("Detected device I/O error");
|
LimeLog.warning("Detected device I/O error");
|
||||||
AbstractXboxController.this.stop();
|
AbstractXboxController.this.stop();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.limelight.binding.input.driver;
|
package com.limelight.binding.input.driver;
|
||||||
|
|
||||||
public interface UsbDriverListener {
|
public interface UsbDriverListener {
|
||||||
void reportControllerState(int controllerId, short buttonFlags,
|
void reportControllerState(int controllerId, int buttonFlags,
|
||||||
float leftStickX, float leftStickY,
|
float leftStickX, float leftStickY,
|
||||||
float rightStickX, float rightStickY,
|
float rightStickX, float rightStickY,
|
||||||
float leftTrigger, float rightTrigger);
|
float leftTrigger, float rightTrigger);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.limelight.binding.input.driver;
|
package com.limelight.binding.input.driver;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
import android.content.BroadcastReceiver;
|
import android.content.BroadcastReceiver;
|
||||||
@@ -20,6 +21,7 @@ import com.limelight.LimeLog;
|
|||||||
import com.limelight.R;
|
import com.limelight.R;
|
||||||
import com.limelight.preferences.PreferenceConfiguration;
|
import com.limelight.preferences.PreferenceConfiguration;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
||||||
public class UsbDriverService extends Service implements UsbDriverListener {
|
public class UsbDriverService extends Service implements UsbDriverListener {
|
||||||
@@ -29,6 +31,7 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
|
|
||||||
private UsbManager usbManager;
|
private UsbManager usbManager;
|
||||||
private PreferenceConfiguration prefConfig;
|
private PreferenceConfiguration prefConfig;
|
||||||
|
private boolean started;
|
||||||
|
|
||||||
private final UsbEventReceiver receiver = new UsbEventReceiver();
|
private final UsbEventReceiver receiver = new UsbEventReceiver();
|
||||||
private final UsbDriverBinder binder = new UsbDriverBinder();
|
private final UsbDriverBinder binder = new UsbDriverBinder();
|
||||||
@@ -36,10 +39,12 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
private final ArrayList<AbstractController> controllers = new ArrayList<>();
|
private final ArrayList<AbstractController> controllers = new ArrayList<>();
|
||||||
|
|
||||||
private UsbDriverListener listener;
|
private UsbDriverListener listener;
|
||||||
|
private UsbDriverStateListener stateListener;
|
||||||
private int nextDeviceId;
|
private int nextDeviceId;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void reportControllerState(int controllerId, short buttonFlags, float leftStickX, float leftStickY, float rightStickX, float rightStickY, float leftTrigger, float rightTrigger) {
|
public void reportControllerState(int controllerId, int buttonFlags, float leftStickX, float leftStickY,
|
||||||
|
float rightStickX, float rightStickY, float leftTrigger, float rightTrigger) {
|
||||||
// Call through to the client's listener
|
// Call through to the client's listener
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
listener.reportControllerState(controllerId, buttonFlags, leftStickX, leftStickY, rightStickX, rightStickY, leftTrigger, rightTrigger);
|
listener.reportControllerState(controllerId, buttonFlags, leftStickX, leftStickY, rightStickX, rightStickY, leftTrigger, rightTrigger);
|
||||||
@@ -93,6 +98,11 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
else if (action.equals(ACTION_USB_PERMISSION)) {
|
else if (action.equals(ACTION_USB_PERMISSION)) {
|
||||||
UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
|
UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
|
||||||
|
|
||||||
|
// Permission dialog is now closed
|
||||||
|
if (stateListener != null) {
|
||||||
|
stateListener.onUsbPermissionPromptCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
// If we got this far, we've already found we're able to handle this device
|
// If we got this far, we've already found we're able to handle this device
|
||||||
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
|
||||||
handleUsbDeviceState(device);
|
handleUsbDeviceState(device);
|
||||||
@@ -112,6 +122,18 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setStateListener(UsbDriverStateListener stateListener) {
|
||||||
|
UsbDriverService.this.stateListener = stateListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
UsbDriverService.this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
UsbDriverService.this.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleUsbDeviceState(UsbDevice device) {
|
private void handleUsbDeviceState(UsbDevice device) {
|
||||||
@@ -121,15 +143,34 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
if (!usbManager.hasPermission(device)) {
|
if (!usbManager.hasPermission(device)) {
|
||||||
// Let's ask for permission
|
// Let's ask for permission
|
||||||
try {
|
try {
|
||||||
|
// Tell the state listener that we're about to display a permission dialog
|
||||||
|
if (stateListener != null) {
|
||||||
|
stateListener.onUsbPermissionPromptStarting();
|
||||||
|
}
|
||||||
|
|
||||||
|
int intentFlags = 0;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
// This PendingIntent must be mutable to allow the framework to populate EXTRA_DEVICE and EXTRA_PERMISSION_GRANTED.
|
||||||
|
intentFlags |= PendingIntent.FLAG_MUTABLE;
|
||||||
|
}
|
||||||
|
|
||||||
// This function is not documented as throwing any exceptions (denying access
|
// This function is not documented as throwing any exceptions (denying access
|
||||||
// is indicated by calling the PendingIntent with a false result). However,
|
// is indicated by calling the PendingIntent with a false result). However,
|
||||||
// Samsung Knox has some policies which block this request, but rather than
|
// Samsung Knox has some policies which block this request, but rather than
|
||||||
// just returning a false result or returning 0 enumerated devices,
|
// just returning a false result or returning 0 enumerated devices,
|
||||||
// they throw an undocumented SecurityException from this call, crashing
|
// they throw an undocumented SecurityException from this call, crashing
|
||||||
// the whole app. :(
|
// the whole app. :(
|
||||||
usbManager.requestPermission(device, PendingIntent.getBroadcast(UsbDriverService.this, 0, new Intent(ACTION_USB_PERMISSION), 0));
|
|
||||||
|
// Use an explicit intent to activate our unexported broadcast receiver, as required on Android 14+
|
||||||
|
Intent i = new Intent(ACTION_USB_PERMISSION);
|
||||||
|
i.setPackage(getPackageName());
|
||||||
|
|
||||||
|
usbManager.requestPermission(device, PendingIntent.getBroadcast(UsbDriverService.this, 0, i, intentFlags));
|
||||||
} catch (SecurityException e) {
|
} catch (SecurityException e) {
|
||||||
Toast.makeText(this, this.getText(R.string.error_usb_prohibited), Toast.LENGTH_LONG).show();
|
Toast.makeText(this, this.getText(R.string.error_usb_prohibited), Toast.LENGTH_LONG).show();
|
||||||
|
if (stateListener != null) {
|
||||||
|
stateListener.onUsbPermissionPromptCompleted();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -150,6 +191,9 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
else if (Xbox360Controller.canClaimDevice(device)) {
|
else if (Xbox360Controller.canClaimDevice(device)) {
|
||||||
controller = new Xbox360Controller(device, connection, nextDeviceId++, this);
|
controller = new Xbox360Controller(device, connection, nextDeviceId++, this);
|
||||||
}
|
}
|
||||||
|
else if (Xbox360WirelessDongle.canClaimDevice(device)) {
|
||||||
|
controller = new Xbox360WirelessDongle(device, connection, nextDeviceId++, this);
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
// Unreachable
|
// Unreachable
|
||||||
return;
|
return;
|
||||||
@@ -167,28 +211,22 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isRecognizedInputDevice(UsbDevice device) {
|
public static boolean isRecognizedInputDevice(UsbDevice device) {
|
||||||
// On KitKat and later, we can determine if this VID and PID combo
|
// Determine if this VID and PID combo matches an existing input device
|
||||||
// matches an existing input device and defer to the built-in controller
|
// and defer to the built-in controller support in that case.
|
||||||
// support in that case. Prior to KitKat, we'll always return true to be safe.
|
for (int id : InputDevice.getDeviceIds()) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
InputDevice inputDev = InputDevice.getDevice(id);
|
||||||
for (int id : InputDevice.getDeviceIds()) {
|
if (inputDev == null) {
|
||||||
InputDevice inputDev = InputDevice.getDevice(id);
|
// Device was removed while looping
|
||||||
if (inputDev == null) {
|
continue;
|
||||||
// Device was removed while looping
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputDev.getVendorId() == device.getVendorId() &&
|
|
||||||
inputDev.getProductId() == device.getProductId()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (inputDev.getVendorId() == device.getVendorId() &&
|
||||||
}
|
inputDev.getProductId() == device.getProductId()) {
|
||||||
else {
|
return true;
|
||||||
return true;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean kernelSupportsXboxOne() {
|
public static boolean kernelSupportsXboxOne() {
|
||||||
@@ -215,21 +253,52 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean shouldClaimDevice(UsbDevice device, boolean claimAllAvailable) {
|
public static boolean kernelSupportsXbox360W() {
|
||||||
return ((!kernelSupportsXboxOne() || !isRecognizedInputDevice(device) || claimAllAvailable) && XboxOneController.canClaimDevice(device)) ||
|
// Check if this kernel is 4.2+ to see if the xpad driver sets Xbox 360 wireless LEDs
|
||||||
((!isRecognizedInputDevice(device) || claimAllAvailable) && Xbox360Controller.canClaimDevice(device));
|
// https://github.com/torvalds/linux/commit/75b7f05d2798ee3a1cc5bbdd54acd0e318a80396
|
||||||
|
String kernelVersion = System.getProperty("os.version");
|
||||||
|
if (kernelVersion != null) {
|
||||||
|
if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.") ||
|
||||||
|
kernelVersion.startsWith("4.0.") || kernelVersion.startsWith("4.1.")) {
|
||||||
|
// Even if LED devices are present, the driver won't set the initial LED state.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We know we have a kernel that should set Xbox 360 wireless LEDs, but we still don't
|
||||||
|
// know if CONFIG_JOYSTICK_XPAD_LEDS was enabled during the kernel build. Unfortunately
|
||||||
|
// it's not possible to detect this reliably due to Android's app sandboxing. Reading
|
||||||
|
// /proc/config.gz and enumerating /sys/class/leds are both blocked by SELinux on any
|
||||||
|
// relatively modern device. We will assume that CONFIG_JOYSTICK_XPAD_LEDS=y on these
|
||||||
|
// kernels and users can override by using the settings option to claim all devices.
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public static boolean shouldClaimDevice(UsbDevice device, boolean claimAllAvailable) {
|
||||||
public void onCreate() {
|
return ((!kernelSupportsXboxOne() || !isRecognizedInputDevice(device) || claimAllAvailable) && XboxOneController.canClaimDevice(device)) ||
|
||||||
this.usbManager = (UsbManager) getSystemService(Context.USB_SERVICE);
|
((!isRecognizedInputDevice(device) || claimAllAvailable) && Xbox360Controller.canClaimDevice(device)) ||
|
||||||
this.prefConfig = PreferenceConfiguration.readPreferences(this);
|
// We must not call isRecognizedInputDevice() because wireless controllers don't share the same product ID as the dongle
|
||||||
|
((!kernelSupportsXbox360W() || claimAllAvailable) && Xbox360WirelessDongle.canClaimDevice(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
|
private void start() {
|
||||||
|
if (started || usbManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
started = true;
|
||||||
|
|
||||||
// Register for USB attach broadcasts and permission completions
|
// Register for USB attach broadcasts and permission completions
|
||||||
IntentFilter filter = new IntentFilter();
|
IntentFilter filter = new IntentFilter();
|
||||||
filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
|
filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
|
||||||
filter.addAction(ACTION_USB_PERMISSION);
|
filter.addAction(ACTION_USB_PERMISSION);
|
||||||
registerReceiver(receiver, filter);
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
registerReceiver(receiver, filter);
|
||||||
|
}
|
||||||
|
|
||||||
// Enumerate existing devices
|
// Enumerate existing devices
|
||||||
for (UsbDevice dev : usbManager.getDeviceList().values()) {
|
for (UsbDevice dev : usbManager.getDeviceList().values()) {
|
||||||
@@ -240,14 +309,16 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void stop() {
|
||||||
public void onDestroy() {
|
if (!started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
started = false;
|
||||||
|
|
||||||
// Stop the attachment receiver
|
// Stop the attachment receiver
|
||||||
unregisterReceiver(receiver);
|
unregisterReceiver(receiver);
|
||||||
|
|
||||||
// Remove listeners
|
|
||||||
listener = null;
|
|
||||||
|
|
||||||
// Stop all controllers
|
// Stop all controllers
|
||||||
while (controllers.size() > 0) {
|
while (controllers.size() > 0) {
|
||||||
// Stop and remove the controller
|
// Stop and remove the controller
|
||||||
@@ -255,8 +326,28 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
this.usbManager = (UsbManager) getSystemService(Context.USB_SERVICE);
|
||||||
|
this.prefConfig = PreferenceConfiguration.readPreferences(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
stop();
|
||||||
|
|
||||||
|
// Remove listeners
|
||||||
|
listener = null;
|
||||||
|
stateListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public IBinder onBind(Intent intent) {
|
public IBinder onBind(Intent intent) {
|
||||||
return binder;
|
return binder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface UsbDriverStateListener {
|
||||||
|
void onUsbPermissionPromptStarting();
|
||||||
|
void onUsbPermissionPromptCompleted();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class Xbox360Controller extends AbstractXboxController {
|
|||||||
0x0f0d, // Hori
|
0x0f0d, // Hori
|
||||||
0x1038, // SteelSeries
|
0x1038, // SteelSeries
|
||||||
0x11c9, // Nacon
|
0x11c9, // Nacon
|
||||||
|
0x1209, // Ardwiino
|
||||||
0x12ab, // Unknown
|
0x12ab, // Unknown
|
||||||
0x1430, // RedOctane
|
0x1430, // RedOctane
|
||||||
0x146b, // BigBen
|
0x146b, // BigBen
|
||||||
@@ -33,8 +34,12 @@ public class Xbox360Controller extends AbstractXboxController {
|
|||||||
0x15e4, // Numark
|
0x15e4, // Numark
|
||||||
0x162e, // Joytech
|
0x162e, // Joytech
|
||||||
0x1689, // Razer Onza
|
0x1689, // Razer Onza
|
||||||
|
0x1949, // Lab126 (Amazon Luna)
|
||||||
0x1bad, // Harmonix
|
0x1bad, // Harmonix
|
||||||
|
0x20d6, // PowerA
|
||||||
0x24c6, // PowerA
|
0x24c6, // PowerA
|
||||||
|
0x2f24, // GameSir
|
||||||
|
0x2dc8, // 8BitDo
|
||||||
};
|
};
|
||||||
|
|
||||||
public static boolean canClaimDevice(UsbDevice device) {
|
public static boolean canClaimDevice(UsbDevice device) {
|
||||||
@@ -66,8 +71,8 @@ public class Xbox360Controller extends AbstractXboxController {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected boolean handleRead(ByteBuffer buffer) {
|
protected boolean handleRead(ByteBuffer buffer) {
|
||||||
if (buffer.limit() < 14) {
|
if (buffer.remaining() < 14) {
|
||||||
LimeLog.severe("Read too small: "+buffer.limit());
|
LimeLog.severe("Read too small: "+buffer.remaining());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,4 +157,9 @@ public class Xbox360Controller extends AbstractXboxController {
|
|||||||
LimeLog.warning("Rumble transfer failed: "+res);
|
LimeLog.warning("Rumble transfer failed: "+res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void rumbleTriggers(short leftTrigger, short rightTrigger) {
|
||||||
|
// Trigger motors not present on Xbox 360 controllers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.limelight.binding.input.driver;
|
||||||
|
|
||||||
|
import android.hardware.usb.UsbConstants;
|
||||||
|
import android.hardware.usb.UsbDevice;
|
||||||
|
import android.hardware.usb.UsbDeviceConnection;
|
||||||
|
import android.hardware.usb.UsbEndpoint;
|
||||||
|
import android.hardware.usb.UsbInterface;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.view.InputDevice;
|
||||||
|
|
||||||
|
import com.limelight.LimeLog;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
|
public class Xbox360WirelessDongle extends AbstractController {
|
||||||
|
private UsbDevice device;
|
||||||
|
private UsbDeviceConnection connection;
|
||||||
|
|
||||||
|
private static final int XB360W_IFACE_SUBCLASS = 93;
|
||||||
|
private static final int XB360W_IFACE_PROTOCOL = 129; // Wireless only
|
||||||
|
|
||||||
|
private static final int[] SUPPORTED_VENDORS = {
|
||||||
|
0x045e, // Microsoft
|
||||||
|
};
|
||||||
|
|
||||||
|
public static boolean canClaimDevice(UsbDevice device) {
|
||||||
|
for (int supportedVid : SUPPORTED_VENDORS) {
|
||||||
|
if (device.getVendorId() == supportedVid &&
|
||||||
|
device.getInterfaceCount() >= 1 &&
|
||||||
|
device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
|
||||||
|
device.getInterface(0).getInterfaceSubclass() == XB360W_IFACE_SUBCLASS &&
|
||||||
|
device.getInterface(0).getInterfaceProtocol() == XB360W_IFACE_PROTOCOL) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Xbox360WirelessDongle(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
|
||||||
|
super(deviceId, listener, device.getVendorId(), device.getProductId());
|
||||||
|
this.device = device;
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendLedCommandToEndpoint(UsbEndpoint endpoint, int controllerIndex) {
|
||||||
|
byte[] commandBuffer = {
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x08,
|
||||||
|
(byte) (0x40 + (2 + (controllerIndex % 4))),
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00};
|
||||||
|
|
||||||
|
int res = connection.bulkTransfer(endpoint, commandBuffer, commandBuffer.length, 3000);
|
||||||
|
if (res != commandBuffer.length) {
|
||||||
|
LimeLog.warning("LED set transfer failed: "+res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendLedCommandToInterface(UsbInterface iface, int controllerIndex) {
|
||||||
|
// Claim this interface to kick xpad off it (temporarily)
|
||||||
|
if (!connection.claimInterface(iface, true)) {
|
||||||
|
LimeLog.warning("Failed to claim interface: "+iface.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the out endpoint for this interface
|
||||||
|
for (int i = 0; i < iface.getEndpointCount(); i++) {
|
||||||
|
UsbEndpoint endpt = iface.getEndpoint(i);
|
||||||
|
if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) {
|
||||||
|
// Send the LED command
|
||||||
|
sendLedCommandToEndpoint(endpt, controllerIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release the interface to allow xpad to take over again
|
||||||
|
connection.releaseInterface(iface);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean start() {
|
||||||
|
int controllerIndex = 0;
|
||||||
|
|
||||||
|
// On Android, there is a controller number associated with input devices.
|
||||||
|
// We can use this to approximate the likely controller number. This won't
|
||||||
|
// be completely accurate because there's no guarantee the order of interfaces
|
||||||
|
// matches the order that devices were enumerated by xpad, but it's probably
|
||||||
|
// better than nothing.
|
||||||
|
for (int id : InputDevice.getDeviceIds()) {
|
||||||
|
InputDevice inputDev = InputDevice.getDevice(id);
|
||||||
|
if (inputDev == null) {
|
||||||
|
// Device was removed while looping
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newer xpad versions use a special product ID (0x02a1) for controllers
|
||||||
|
// rather than copying the product ID of the dongle itself.
|
||||||
|
if (inputDev.getVendorId() == device.getVendorId() &&
|
||||||
|
(inputDev.getProductId() == device.getProductId() ||
|
||||||
|
inputDev.getProductId() == 0x02a1) &&
|
||||||
|
inputDev.getControllerNumber() > 0) {
|
||||||
|
controllerIndex = inputDev.getControllerNumber() - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send LED commands on the out endpoint of each interface. There is one interface
|
||||||
|
// corresponding to each possible attached controller.
|
||||||
|
for (int i = 0; i < device.getInterfaceCount(); i++) {
|
||||||
|
UsbInterface iface = device.getInterface(i);
|
||||||
|
|
||||||
|
// Skip the non-input interfaces
|
||||||
|
if (iface.getInterfaceClass() != UsbConstants.USB_CLASS_VENDOR_SPEC ||
|
||||||
|
iface.getInterfaceSubclass() != XB360W_IFACE_SUBCLASS ||
|
||||||
|
iface.getInterfaceProtocol() != XB360W_IFACE_PROTOCOL) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendLedCommandToInterface(iface, controllerIndex++);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Fail" to give control back to the kernel driver
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
// Nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void rumble(short lowFreqMotor, short highFreqMotor) {
|
||||||
|
// Unreachable.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void rumbleTriggers(short leftTrigger, short rightTrigger) {
|
||||||
|
// Unreachable.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import android.hardware.usb.UsbDeviceConnection;
|
|||||||
|
|
||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
import com.limelight.nvstream.input.ControllerPacket;
|
import com.limelight.nvstream.input.ControllerPacket;
|
||||||
|
import com.limelight.nvstream.jni.MoonBridge;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -21,7 +22,9 @@ public class XboxOneController extends AbstractXboxController {
|
|||||||
0x0e6f, // Unknown
|
0x0e6f, // Unknown
|
||||||
0x0f0d, // Hori
|
0x0f0d, // Hori
|
||||||
0x1532, // Razer Wildcat
|
0x1532, // Razer Wildcat
|
||||||
|
0x20d6, // PowerA
|
||||||
0x24c6, // PowerA
|
0x24c6, // PowerA
|
||||||
|
0x2e24, // Hyperkin
|
||||||
};
|
};
|
||||||
|
|
||||||
private static final byte[] FW2015_INIT = {0x05, 0x20, 0x00, 0x01, 0x00};
|
private static final byte[] FW2015_INIT = {0x05, 0x20, 0x00, 0x01, 0x00};
|
||||||
@@ -52,9 +55,14 @@ public class XboxOneController extends AbstractXboxController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private byte seqNum = 0;
|
private byte seqNum = 0;
|
||||||
|
private short lowFreqMotor = 0;
|
||||||
|
private short highFreqMotor = 0;
|
||||||
|
private short leftTriggerMotor = 0;
|
||||||
|
private short rightTriggerMotor = 0;
|
||||||
|
|
||||||
public XboxOneController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
|
public XboxOneController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
|
||||||
super(device, connection, deviceId, listener);
|
super(device, connection, deviceId, listener);
|
||||||
|
capabilities |= MoonBridge.LI_CCAP_TRIGGER_RUMBLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processButtons(ByteBuffer buffer) {
|
private void processButtons(ByteBuffer buffer) {
|
||||||
@@ -101,11 +109,21 @@ public class XboxOneController extends AbstractXboxController {
|
|||||||
switch (buffer.get())
|
switch (buffer.get())
|
||||||
{
|
{
|
||||||
case 0x20:
|
case 0x20:
|
||||||
|
if (buffer.remaining() < 17) {
|
||||||
|
LimeLog.severe("XBone button/axis read too small: "+buffer.remaining());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
buffer.position(buffer.position()+3);
|
buffer.position(buffer.position()+3);
|
||||||
processButtons(buffer);
|
processButtons(buffer);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 0x07:
|
case 0x07:
|
||||||
|
if (buffer.remaining() < 4) {
|
||||||
|
LimeLog.severe("XBone mode read too small: "+buffer.remaining());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// The Xbox One S controller needs acks for mode reports otherwise
|
// The Xbox One S controller needs acks for mode reports otherwise
|
||||||
// it retransmits them forever.
|
// it retransmits them forever.
|
||||||
if (buffer.get() == 0x30) {
|
if (buffer.get() == 0x30) {
|
||||||
@@ -164,12 +182,14 @@ public class XboxOneController extends AbstractXboxController {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void sendRumblePacket() {
|
||||||
public void rumble(short lowFreqMotor, short highFreqMotor) {
|
|
||||||
byte[] data = {
|
byte[] data = {
|
||||||
0x09, 0x00, seqNum++, 0x09, 0x00,
|
0x09, 0x00, seqNum++, 0x09, 0x00,
|
||||||
0x0F, 0x00, 0x00,
|
0x0F,
|
||||||
(byte)(lowFreqMotor >> 9), (byte)(highFreqMotor >> 9),
|
(byte)(leftTriggerMotor >> 9),
|
||||||
|
(byte)(rightTriggerMotor >> 9),
|
||||||
|
(byte)(lowFreqMotor >> 9),
|
||||||
|
(byte)(highFreqMotor >> 9),
|
||||||
(byte)0xFF, 0x00, (byte)0xFF
|
(byte)0xFF, 0x00, (byte)0xFF
|
||||||
};
|
};
|
||||||
int res = connection.bulkTransfer(outEndpt, data, data.length, 100);
|
int res = connection.bulkTransfer(outEndpt, data, data.length, 100);
|
||||||
@@ -178,6 +198,20 @@ public class XboxOneController extends AbstractXboxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void rumble(short lowFreqMotor, short highFreqMotor) {
|
||||||
|
this.lowFreqMotor = lowFreqMotor;
|
||||||
|
this.highFreqMotor = highFreqMotor;
|
||||||
|
sendRumblePacket();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void rumbleTriggers(short leftTrigger, short rightTrigger) {
|
||||||
|
this.leftTriggerMotor = leftTrigger;
|
||||||
|
this.rightTriggerMotor = rightTrigger;
|
||||||
|
sendRumblePacket();
|
||||||
|
}
|
||||||
|
|
||||||
private static class InitPacket {
|
private static class InitPacket {
|
||||||
final int vendorId;
|
final int vendorId;
|
||||||
final int productId;
|
final int productId;
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package com.limelight.binding.input.evdev;
|
|||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
|
|
||||||
import com.limelight.LimelightBuildProps;
|
import com.limelight.BuildConfig;
|
||||||
import com.limelight.binding.input.capture.InputCaptureProvider;
|
import com.limelight.binding.input.capture.InputCaptureProvider;
|
||||||
|
|
||||||
public class EvdevCaptureProviderShim {
|
public class EvdevCaptureProviderShim {
|
||||||
public static boolean isCaptureProviderSupported() {
|
public static boolean isCaptureProviderSupported() {
|
||||||
return LimelightBuildProps.ROOT_BUILD;
|
return BuildConfig.ROOT_BUILD;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to construct our capture provider using reflection because it isn't included in non-root builds
|
// We need to construct our capture provider using reflection because it isn't included in non-root builds
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public interface EvdevListener {
|
|||||||
|
|
||||||
void mouseMove(int deltaX, int deltaY);
|
void mouseMove(int deltaX, int deltaY);
|
||||||
void mouseButtonEvent(int buttonId, boolean down);
|
void mouseButtonEvent(int buttonId, boolean down);
|
||||||
void mouseScroll(byte amount);
|
void mouseVScroll(byte amount);
|
||||||
|
void mouseHScroll(byte amount);
|
||||||
void keyboardEvent(boolean buttonDown, short keyCode);
|
void keyboardEvent(boolean buttonDown, short keyCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
package com.limelight.binding.input.touch;
|
package com.limelight.binding.input.touch;
|
||||||
|
|
||||||
import android.os.SystemClock;
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
|
||||||
import com.limelight.nvstream.NvConnection;
|
import com.limelight.nvstream.NvConnection;
|
||||||
import com.limelight.nvstream.input.MouseButtonPacket;
|
import com.limelight.nvstream.input.MouseButtonPacket;
|
||||||
|
|
||||||
import java.util.Timer;
|
|
||||||
import java.util.TimerTask;
|
|
||||||
|
|
||||||
public class AbsoluteTouchContext implements TouchContext {
|
public class AbsoluteTouchContext implements TouchContext {
|
||||||
private int lastTouchDownX = 0;
|
private int lastTouchDownX = 0;
|
||||||
private int lastTouchDownY = 0;
|
private int lastTouchDownY = 0;
|
||||||
@@ -21,15 +19,43 @@ public class AbsoluteTouchContext implements TouchContext {
|
|||||||
private boolean cancelled;
|
private boolean cancelled;
|
||||||
private boolean confirmedLongPress;
|
private boolean confirmedLongPress;
|
||||||
private boolean confirmedTap;
|
private boolean confirmedTap;
|
||||||
private Timer longPressTimer;
|
|
||||||
private Timer tapDownTimer;
|
private final Runnable longPressRunnable = new Runnable() {
|
||||||
private float accumulatedScrollDelta;
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// This timer should have already expired, but cancel it just in case
|
||||||
|
cancelTapDownTimer();
|
||||||
|
|
||||||
|
// Switch from a left click to a right click after a long press
|
||||||
|
confirmedLongPress = true;
|
||||||
|
if (confirmedTap) {
|
||||||
|
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
|
||||||
|
}
|
||||||
|
conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private final Runnable tapDownRunnable = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// Start our tap
|
||||||
|
tapConfirmed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private final NvConnection conn;
|
private final NvConnection conn;
|
||||||
private final int actionIndex;
|
private final int actionIndex;
|
||||||
private final View targetView;
|
private final View targetView;
|
||||||
|
private final Handler handler;
|
||||||
|
|
||||||
private static final int SCROLL_SPEED_DIVISOR = 20;
|
private final Runnable leftButtonUpRunnable = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final int SCROLL_SPEED_FACTOR = 3;
|
||||||
|
|
||||||
private static final int LONG_PRESS_TIME_THRESHOLD = 650;
|
private static final int LONG_PRESS_TIME_THRESHOLD = 650;
|
||||||
private static final int LONG_PRESS_DISTANCE_THRESHOLD = 30;
|
private static final int LONG_PRESS_DISTANCE_THRESHOLD = 30;
|
||||||
@@ -45,6 +71,7 @@ public class AbsoluteTouchContext implements TouchContext {
|
|||||||
this.conn = conn;
|
this.conn = conn;
|
||||||
this.actionIndex = actionIndex;
|
this.actionIndex = actionIndex;
|
||||||
this.targetView = view;
|
this.targetView = view;
|
||||||
|
this.handler = new Handler(Looper.getMainLooper());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -54,7 +81,7 @@ public class AbsoluteTouchContext implements TouchContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean touchDownEvent(int eventX, int eventY, boolean isNewFinger)
|
public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger)
|
||||||
{
|
{
|
||||||
if (!isNewFinger) {
|
if (!isNewFinger) {
|
||||||
// We don't handle finger transitions for absolute mode
|
// We don't handle finger transitions for absolute mode
|
||||||
@@ -63,9 +90,8 @@ public class AbsoluteTouchContext implements TouchContext {
|
|||||||
|
|
||||||
lastTouchLocationX = lastTouchDownX = eventX;
|
lastTouchLocationX = lastTouchDownX = eventX;
|
||||||
lastTouchLocationY = lastTouchDownY = eventY;
|
lastTouchLocationY = lastTouchDownY = eventY;
|
||||||
lastTouchDownTime = SystemClock.uptimeMillis();
|
lastTouchDownTime = eventTime;
|
||||||
cancelled = confirmedTap = confirmedLongPress = false;
|
cancelled = confirmedTap = confirmedLongPress = false;
|
||||||
accumulatedScrollDelta = 0;
|
|
||||||
|
|
||||||
if (actionIndex == 0) {
|
if (actionIndex == 0) {
|
||||||
// Start the timers
|
// Start the timers
|
||||||
@@ -92,7 +118,7 @@ public class AbsoluteTouchContext implements TouchContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void touchUpEvent(int eventX, int eventY)
|
public void touchUpEvent(int eventX, int eventY, long eventTime)
|
||||||
{
|
{
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -115,80 +141,35 @@ public class AbsoluteTouchContext implements TouchContext {
|
|||||||
// deadzone time. We'll need to send the touch down and up events now at the
|
// deadzone time. We'll need to send the touch down and up events now at the
|
||||||
// original touch down position.
|
// original touch down position.
|
||||||
tapConfirmed();
|
tapConfirmed();
|
||||||
try {
|
|
||||||
// FIXME: Sleeping on the main thread sucks
|
// Release the left mouse button in 100ms to allow for apps that use polling
|
||||||
Thread.sleep(50);
|
// to detect mouse button presses.
|
||||||
} catch (InterruptedException ignored) {}
|
handler.removeCallbacks(leftButtonUpRunnable);
|
||||||
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
|
handler.postDelayed(leftButtonUpRunnable, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastTouchLocationX = lastTouchUpX = eventX;
|
lastTouchLocationX = lastTouchUpX = eventX;
|
||||||
lastTouchLocationY = lastTouchUpY = eventY;
|
lastTouchLocationY = lastTouchUpY = eventY;
|
||||||
lastTouchUpTime = SystemClock.uptimeMillis();
|
lastTouchUpTime = eventTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void startLongPressTimer() {
|
private void startLongPressTimer() {
|
||||||
longPressTimer = new Timer(true);
|
cancelLongPressTimer();
|
||||||
longPressTimer.schedule(new TimerTask() {
|
handler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD);
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
synchronized (AbsoluteTouchContext.this) {
|
|
||||||
// Check if someone cancelled us
|
|
||||||
if (longPressTimer == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uncancellable now
|
|
||||||
longPressTimer = null;
|
|
||||||
|
|
||||||
// This timer should have already expired, but cancel it just in case
|
|
||||||
cancelTapDownTimer();
|
|
||||||
|
|
||||||
// Switch from a left click to a right click after a long press
|
|
||||||
confirmedLongPress = true;
|
|
||||||
if (confirmedTap) {
|
|
||||||
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
|
|
||||||
}
|
|
||||||
conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, LONG_PRESS_TIME_THRESHOLD);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void cancelLongPressTimer() {
|
private void cancelLongPressTimer() {
|
||||||
if (longPressTimer != null) {
|
handler.removeCallbacks(longPressRunnable);
|
||||||
longPressTimer.cancel();
|
|
||||||
longPressTimer = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void startTapDownTimer() {
|
private void startTapDownTimer() {
|
||||||
tapDownTimer = new Timer(true);
|
cancelTapDownTimer();
|
||||||
tapDownTimer.schedule(new TimerTask() {
|
handler.postDelayed(tapDownRunnable, TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD);
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
synchronized (AbsoluteTouchContext.this) {
|
|
||||||
// Check if someone cancelled us
|
|
||||||
if (tapDownTimer == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uncancellable now
|
|
||||||
tapDownTimer = null;
|
|
||||||
|
|
||||||
// Start our tap
|
|
||||||
tapConfirmed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void cancelTapDownTimer() {
|
private void cancelTapDownTimer() {
|
||||||
if (tapDownTimer != null) {
|
handler.removeCallbacks(tapDownRunnable);
|
||||||
tapDownTimer.cancel();
|
|
||||||
tapDownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void tapConfirmed() {
|
private void tapConfirmed() {
|
||||||
@@ -209,7 +190,7 @@ public class AbsoluteTouchContext implements TouchContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean touchMoveEvent(int eventX, int eventY)
|
public boolean touchMoveEvent(int eventX, int eventY, long eventTime)
|
||||||
{
|
{
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return true;
|
return true;
|
||||||
@@ -228,11 +209,7 @@ public class AbsoluteTouchContext implements TouchContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (actionIndex == 1) {
|
else if (actionIndex == 1) {
|
||||||
accumulatedScrollDelta += (eventY - lastTouchLocationY) / (float)SCROLL_SPEED_DIVISOR;
|
conn.sendMouseHighResScroll((short)((eventY - lastTouchLocationY) * SCROLL_SPEED_FACTOR));
|
||||||
if ((short)accumulatedScrollDelta != 0) {
|
|
||||||
conn.sendMouseHighResScroll((short)accumulatedScrollDelta);
|
|
||||||
accumulatedScrollDelta -= (short)accumulatedScrollDelta;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastTouchLocationX = eventX;
|
lastTouchLocationX = eventX;
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
package com.limelight.binding.input.touch;
|
package com.limelight.binding.input.touch;
|
||||||
|
|
||||||
import android.os.SystemClock;
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
|
||||||
import com.limelight.nvstream.NvConnection;
|
import com.limelight.nvstream.NvConnection;
|
||||||
import com.limelight.nvstream.input.MouseButtonPacket;
|
import com.limelight.nvstream.input.MouseButtonPacket;
|
||||||
|
import com.limelight.preferences.PreferenceConfiguration;
|
||||||
import java.util.Timer;
|
|
||||||
import java.util.TimerTask;
|
|
||||||
|
|
||||||
public class RelativeTouchContext implements TouchContext {
|
public class RelativeTouchContext implements TouchContext {
|
||||||
private int lastTouchX = 0;
|
private int lastTouchX = 0;
|
||||||
@@ -18,29 +17,91 @@ public class RelativeTouchContext implements TouchContext {
|
|||||||
private boolean cancelled;
|
private boolean cancelled;
|
||||||
private boolean confirmedMove;
|
private boolean confirmedMove;
|
||||||
private boolean confirmedDrag;
|
private boolean confirmedDrag;
|
||||||
private Timer dragTimer;
|
private boolean confirmedScroll;
|
||||||
private double distanceMoved;
|
private double distanceMoved;
|
||||||
private double xFactor, yFactor;
|
private double xFactor, yFactor;
|
||||||
|
private int pointerCount;
|
||||||
|
private int maxPointerCountInGesture;
|
||||||
|
|
||||||
private final NvConnection conn;
|
private final NvConnection conn;
|
||||||
private final int actionIndex;
|
private final int actionIndex;
|
||||||
private final int referenceWidth;
|
private final int referenceWidth;
|
||||||
private final int referenceHeight;
|
private final int referenceHeight;
|
||||||
private final View targetView;
|
private final View targetView;
|
||||||
|
private final PreferenceConfiguration prefConfig;
|
||||||
|
private final Handler handler;
|
||||||
|
|
||||||
|
private final Runnable dragTimerRunnable = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// Check if someone already set move
|
||||||
|
if (confirmedMove) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The drag should only be processed for the primary finger
|
||||||
|
if (actionIndex != maxPointerCountInGesture - 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We haven't been cancelled before the timer expired so begin dragging
|
||||||
|
confirmedDrag = true;
|
||||||
|
conn.sendMouseButtonDown(getMouseButtonIndex());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Indexed by MouseButtonPacket.BUTTON_XXX - 1
|
||||||
|
private final Runnable[] buttonUpRunnables = new Runnable[] {
|
||||||
|
new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private static final int TAP_MOVEMENT_THRESHOLD = 20;
|
private static final int TAP_MOVEMENT_THRESHOLD = 20;
|
||||||
private static final int TAP_DISTANCE_THRESHOLD = 25;
|
private static final int TAP_DISTANCE_THRESHOLD = 25;
|
||||||
private static final int TAP_TIME_THRESHOLD = 250;
|
private static final int TAP_TIME_THRESHOLD = 250;
|
||||||
private static final int DRAG_TIME_THRESHOLD = 650;
|
private static final int DRAG_TIME_THRESHOLD = 650;
|
||||||
|
|
||||||
|
private static final int SCROLL_SPEED_FACTOR = 5;
|
||||||
|
|
||||||
public RelativeTouchContext(NvConnection conn, int actionIndex,
|
public RelativeTouchContext(NvConnection conn, int actionIndex,
|
||||||
int referenceWidth, int referenceHeight, View view)
|
int referenceWidth, int referenceHeight,
|
||||||
|
View view, PreferenceConfiguration prefConfig)
|
||||||
{
|
{
|
||||||
this.conn = conn;
|
this.conn = conn;
|
||||||
this.actionIndex = actionIndex;
|
this.actionIndex = actionIndex;
|
||||||
this.referenceWidth = referenceWidth;
|
this.referenceWidth = referenceWidth;
|
||||||
this.referenceHeight = referenceHeight;
|
this.referenceHeight = referenceHeight;
|
||||||
this.targetView = view;
|
this.targetView = view;
|
||||||
|
this.prefConfig = prefConfig;
|
||||||
|
this.handler = new Handler(Looper.getMainLooper());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -57,10 +118,20 @@ public class RelativeTouchContext implements TouchContext {
|
|||||||
yDelta <= TAP_MOVEMENT_THRESHOLD;
|
yDelta <= TAP_MOVEMENT_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isTap()
|
private boolean isTap(long eventTime)
|
||||||
{
|
{
|
||||||
long timeDelta = SystemClock.uptimeMillis() - originalTouchTime;
|
if (confirmedDrag || confirmedMove || confirmedScroll) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this input wasn't the last finger down, do not report
|
||||||
|
// a tap. This ensures we don't report duplicate taps for each
|
||||||
|
// finger on a multi-finger tap gesture
|
||||||
|
if (actionIndex + 1 != maxPointerCountInGesture) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long timeDelta = eventTime - originalTouchTime;
|
||||||
return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD;
|
return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +146,7 @@ public class RelativeTouchContext implements TouchContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean touchDownEvent(int eventX, int eventY, boolean isNewFinger)
|
public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger)
|
||||||
{
|
{
|
||||||
// Get the view dimensions to scale inputs on this touch
|
// Get the view dimensions to scale inputs on this touch
|
||||||
xFactor = referenceWidth / (double)targetView.getWidth();
|
xFactor = referenceWidth / (double)targetView.getWidth();
|
||||||
@@ -83,20 +154,24 @@ public class RelativeTouchContext implements TouchContext {
|
|||||||
|
|
||||||
originalTouchX = lastTouchX = eventX;
|
originalTouchX = lastTouchX = eventX;
|
||||||
originalTouchY = lastTouchY = eventY;
|
originalTouchY = lastTouchY = eventY;
|
||||||
originalTouchTime = SystemClock.uptimeMillis();
|
|
||||||
cancelled = confirmedDrag = confirmedMove = false;
|
|
||||||
distanceMoved = 0;
|
|
||||||
|
|
||||||
if (actionIndex == 0) {
|
if (isNewFinger) {
|
||||||
// Start the timer for engaging a drag
|
maxPointerCountInGesture = pointerCount;
|
||||||
startDragTimer();
|
originalTouchTime = eventTime;
|
||||||
|
cancelled = confirmedDrag = confirmedMove = confirmedScroll = false;
|
||||||
|
distanceMoved = 0;
|
||||||
|
|
||||||
|
if (actionIndex == 0) {
|
||||||
|
// Start the timer for engaging a drag
|
||||||
|
startDragTimer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void touchUpEvent(int eventX, int eventY)
|
public void touchUpEvent(int eventX, int eventY, long eventTime)
|
||||||
{
|
{
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -111,57 +186,29 @@ public class RelativeTouchContext implements TouchContext {
|
|||||||
// Raise the button after a drag
|
// Raise the button after a drag
|
||||||
conn.sendMouseButtonUp(buttonIndex);
|
conn.sendMouseButtonUp(buttonIndex);
|
||||||
}
|
}
|
||||||
else if (isTap())
|
else if (isTap(eventTime))
|
||||||
{
|
{
|
||||||
// Lower the mouse button
|
// Lower the mouse button
|
||||||
conn.sendMouseButtonDown(buttonIndex);
|
conn.sendMouseButtonDown(buttonIndex);
|
||||||
|
|
||||||
// We need to sleep a bit here because some games
|
// Release the mouse button in 100ms to allow for apps that use polling
|
||||||
// do input detection by polling
|
// to detect mouse button presses.
|
||||||
try {
|
Runnable buttonUpRunnable = buttonUpRunnables[buttonIndex - 1];
|
||||||
Thread.sleep(100);
|
handler.removeCallbacks(buttonUpRunnable);
|
||||||
} catch (InterruptedException ignored) {}
|
handler.postDelayed(buttonUpRunnable, 100);
|
||||||
|
|
||||||
// Raise the mouse button
|
|
||||||
conn.sendMouseButtonUp(buttonIndex);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void startDragTimer() {
|
private void startDragTimer() {
|
||||||
dragTimer = new Timer(true);
|
cancelDragTimer();
|
||||||
dragTimer.schedule(new TimerTask() {
|
handler.postDelayed(dragTimerRunnable, DRAG_TIME_THRESHOLD);
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
synchronized (RelativeTouchContext.this) {
|
|
||||||
// Check if someone already set move
|
|
||||||
if (confirmedMove) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if someone cancelled us
|
|
||||||
if (dragTimer == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uncancellable now
|
|
||||||
dragTimer = null;
|
|
||||||
|
|
||||||
// We haven't been cancelled before the timer expired so begin dragging
|
|
||||||
confirmedDrag = true;
|
|
||||||
conn.sendMouseButtonDown(getMouseButtonIndex());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, DRAG_TIME_THRESHOLD);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void cancelDragTimer() {
|
private void cancelDragTimer() {
|
||||||
if (dragTimer != null) {
|
handler.removeCallbacks(dragTimerRunnable);
|
||||||
dragTimer.cancel();
|
|
||||||
dragTimer = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void checkForConfirmedMove(int eventX, int eventY) {
|
private void checkForConfirmedMove(int eventX, int eventY) {
|
||||||
// If we've already confirmed something, get out now
|
// If we've already confirmed something, get out now
|
||||||
if (confirmedMove || confirmedDrag) {
|
if (confirmedMove || confirmedDrag) {
|
||||||
return;
|
return;
|
||||||
@@ -183,21 +230,33 @@ public class RelativeTouchContext implements TouchContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void checkForConfirmedScroll() {
|
||||||
|
// Enter scrolling mode if we've already left the tap zone
|
||||||
|
// and we have 2 fingers on screen. Leave scroll mode if
|
||||||
|
// we no longer have 2 fingers on screen
|
||||||
|
confirmedScroll = (actionIndex == 0 && pointerCount == 2 && confirmedMove);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean touchMoveEvent(int eventX, int eventY)
|
public boolean touchMoveEvent(int eventX, int eventY, long eventTime)
|
||||||
{
|
{
|
||||||
|
if (cancelled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (eventX != lastTouchX || eventY != lastTouchY)
|
if (eventX != lastTouchX || eventY != lastTouchY)
|
||||||
{
|
{
|
||||||
|
checkForConfirmedMove(eventX, eventY);
|
||||||
|
checkForConfirmedScroll();
|
||||||
|
|
||||||
// We only send moves and drags for the primary touch point
|
// We only send moves and drags for the primary touch point
|
||||||
if (actionIndex == 0) {
|
if (actionIndex == 0) {
|
||||||
checkForConfirmedMove(eventX, eventY);
|
|
||||||
|
|
||||||
int deltaX = eventX - lastTouchX;
|
int deltaX = eventX - lastTouchX;
|
||||||
int deltaY = eventY - lastTouchY;
|
int deltaY = eventY - lastTouchY;
|
||||||
|
|
||||||
// Scale the deltas based on the factors passed to our constructor
|
// Scale the deltas based on the factors passed to our constructor
|
||||||
deltaX = (int)Math.round((double)Math.abs(deltaX) * xFactor);
|
deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor);
|
||||||
deltaY = (int)Math.round((double)Math.abs(deltaY) * yFactor);
|
deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor);
|
||||||
|
|
||||||
// Fix up the signs
|
// Fix up the signs
|
||||||
if (eventX < lastTouchX) {
|
if (eventX < lastTouchX) {
|
||||||
@@ -207,6 +266,23 @@ public class RelativeTouchContext implements TouchContext {
|
|||||||
deltaY = -deltaY;
|
deltaY = -deltaY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pointerCount == 2) {
|
||||||
|
if (confirmedScroll) {
|
||||||
|
conn.sendMouseHighResScroll((short)(deltaY * SCROLL_SPEED_FACTOR));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (prefConfig.absoluteMouseMode) {
|
||||||
|
conn.sendMouseMoveAsMousePosition(
|
||||||
|
(short) deltaX,
|
||||||
|
(short) deltaY,
|
||||||
|
(short) targetView.getWidth(),
|
||||||
|
(short) targetView.getHeight());
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
conn.sendMouseMove((short) deltaX, (short) deltaY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If the scaling factor ended up rounding deltas to zero, wait until they are
|
// If the scaling factor ended up rounding deltas to zero, wait until they are
|
||||||
// non-zero to update lastTouch that way devices that report small touch events often
|
// non-zero to update lastTouch that way devices that report small touch events often
|
||||||
// will work correctly
|
// will work correctly
|
||||||
@@ -216,8 +292,6 @@ public class RelativeTouchContext implements TouchContext {
|
|||||||
if (deltaY != 0) {
|
if (deltaY != 0) {
|
||||||
lastTouchY = eventY;
|
lastTouchY = eventY;
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.sendMouseMove((short)deltaX, (short)deltaY);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
lastTouchX = eventX;
|
lastTouchX = eventX;
|
||||||
@@ -247,5 +321,11 @@ public class RelativeTouchContext implements TouchContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setPointerCount(int pointerCount) {}
|
public void setPointerCount(int pointerCount) {
|
||||||
|
this.pointerCount = pointerCount;
|
||||||
|
|
||||||
|
if (pointerCount > maxPointerCountInGesture) {
|
||||||
|
maxPointerCountInGesture = pointerCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package com.limelight.binding.input.touch;
|
|||||||
public interface TouchContext {
|
public interface TouchContext {
|
||||||
int getActionIndex();
|
int getActionIndex();
|
||||||
void setPointerCount(int pointerCount);
|
void setPointerCount(int pointerCount);
|
||||||
boolean touchDownEvent(int eventX, int eventY, boolean isNewFinger);
|
boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger);
|
||||||
boolean touchMoveEvent(int eventX, int eventY);
|
boolean touchMoveEvent(int eventX, int eventY, long eventTime);
|
||||||
void touchUpEvent(int eventX, int eventY);
|
void touchUpEvent(int eventX, int eventY, long eventTime);
|
||||||
void cancelTouch();
|
void cancelTouch();
|
||||||
boolean isCancelled();
|
boolean isCancelled();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ public class AnalogStick extends VirtualControllerElement {
|
|||||||
@Override
|
@Override
|
||||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||||
// calculate new radius sizes depending
|
// calculate new radius sizes depending
|
||||||
radius_complete = getPercent(getCorrectWidth() / 2, 100);
|
radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth();
|
||||||
radius_dead_zone = getPercent(getCorrectWidth() / 2, 30);
|
radius_dead_zone = getPercent(getCorrectWidth() / 2, 30);
|
||||||
radius_analog_stick = getPercent(getCorrectWidth() / 2, 20);
|
radius_analog_stick = getPercent(getCorrectWidth() / 2, 20);
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ public class AnalogStick extends VirtualControllerElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updatePosition() {
|
private void updatePosition(long eventTime) {
|
||||||
// get 100% way
|
// get 100% way
|
||||||
float complete = radius_complete - radius_analog_stick;
|
float complete = radius_complete - radius_analog_stick;
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ public class AnalogStick extends VirtualControllerElement {
|
|||||||
// We also release the deadzone if the user keeps the stick pressed for a bit to allow
|
// We also release the deadzone if the user keeps the stick pressed for a bit to allow
|
||||||
// them to make precise movements.
|
// them to make precise movements.
|
||||||
stick_state = (stick_state == STICK_STATE.MOVED_ACTIVE ||
|
stick_state = (stick_state == STICK_STATE.MOVED_ACTIVE ||
|
||||||
System.currentTimeMillis() - timeLastClick > timeoutDeadzone ||
|
eventTime - timeLastClick > timeoutDeadzone ||
|
||||||
movement_radius > radius_dead_zone) ?
|
movement_radius > radius_dead_zone) ?
|
||||||
STICK_STATE.MOVED_ACTIVE : STICK_STATE.MOVED_IN_DEAD_ZONE;
|
STICK_STATE.MOVED_ACTIVE : STICK_STATE.MOVED_IN_DEAD_ZONE;
|
||||||
|
|
||||||
@@ -305,13 +305,12 @@ public class AnalogStick extends VirtualControllerElement {
|
|||||||
// handle event depending on action
|
// handle event depending on action
|
||||||
switch (event.getActionMasked()) {
|
switch (event.getActionMasked()) {
|
||||||
// down event (touch event)
|
// down event (touch event)
|
||||||
case MotionEvent.ACTION_DOWN:
|
case MotionEvent.ACTION_DOWN: {
|
||||||
case MotionEvent.ACTION_POINTER_DOWN: {
|
|
||||||
// set to dead zoned, will be corrected in update position if necessary
|
// set to dead zoned, will be corrected in update position if necessary
|
||||||
stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE;
|
stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE;
|
||||||
// check for double click
|
// check for double click
|
||||||
if (lastClickState == CLICK_STATE.SINGLE &&
|
if (lastClickState == CLICK_STATE.SINGLE &&
|
||||||
timeLastClick + timeoutDoubleClick > System.currentTimeMillis()) {
|
event.getEventTime() - timeLastClick <= timeoutDoubleClick) {
|
||||||
click_state = CLICK_STATE.DOUBLE;
|
click_state = CLICK_STATE.DOUBLE;
|
||||||
notifyOnDoubleClick();
|
notifyOnDoubleClick();
|
||||||
} else {
|
} else {
|
||||||
@@ -319,14 +318,14 @@ public class AnalogStick extends VirtualControllerElement {
|
|||||||
notifyOnClick();
|
notifyOnClick();
|
||||||
}
|
}
|
||||||
// reset last click timestamp
|
// reset last click timestamp
|
||||||
timeLastClick = System.currentTimeMillis();
|
timeLastClick = event.getEventTime();
|
||||||
// set item pressed and update
|
// set item pressed and update
|
||||||
setPressed(true);
|
setPressed(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// up event (revoke touch)
|
// up event (revoke touch)
|
||||||
case MotionEvent.ACTION_UP:
|
case MotionEvent.ACTION_CANCEL:
|
||||||
case MotionEvent.ACTION_POINTER_UP: {
|
case MotionEvent.ACTION_UP: {
|
||||||
setPressed(false);
|
setPressed(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -334,7 +333,7 @@ public class AnalogStick extends VirtualControllerElement {
|
|||||||
|
|
||||||
if (isPressed()) {
|
if (isPressed()) {
|
||||||
// when is pressed calculate new positions (will trigger movement if necessary)
|
// when is pressed calculate new positions (will trigger movement if necessary)
|
||||||
updatePosition();
|
updatePosition(event.getEventTime());
|
||||||
} else {
|
} else {
|
||||||
stick_state = STICK_STATE.NO_MOVEMENT;
|
stick_state = STICK_STATE.NO_MOVEMENT;
|
||||||
notifyOnRevoke();
|
notifyOnRevoke();
|
||||||
|
|||||||
+11
-27
@@ -14,8 +14,6 @@ import android.view.MotionEvent;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Timer;
|
|
||||||
import java.util.TimerTask;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is a digital button on screen element. It is used to get click and double click user input.
|
* This is a digital button on screen element. It is used to get click and double click user input.
|
||||||
@@ -43,22 +41,16 @@ public class DigitalButton extends VirtualControllerElement {
|
|||||||
void onRelease();
|
void onRelease();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
private class TimerLongClickTimerTask extends TimerTask {
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
onLongClickCallback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<DigitalButtonListener> listeners = new ArrayList<>();
|
private List<DigitalButtonListener> listeners = new ArrayList<>();
|
||||||
private String text = "";
|
private String text = "";
|
||||||
private int icon = -1;
|
private int icon = -1;
|
||||||
private long timerLongClickTimeout = 3000;
|
private long timerLongClickTimeout = 3000;
|
||||||
private Timer timerLongClick = null;
|
private final Runnable longClickRunnable = new Runnable() {
|
||||||
private TimerLongClickTimerTask longClickTimerTask = null;
|
@Override
|
||||||
|
public void run() {
|
||||||
|
onLongClickCallback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private final Paint paint = new Paint();
|
private final Paint paint = new Paint();
|
||||||
private final RectF rect = new RectF();
|
private final RectF rect = new RectF();
|
||||||
@@ -177,9 +169,8 @@ public class DigitalButton extends VirtualControllerElement {
|
|||||||
listener.onClick();
|
listener.onClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
timerLongClick = new Timer();
|
virtualController.getHandler().removeCallbacks(longClickRunnable);
|
||||||
longClickTimerTask = new TimerLongClickTimerTask();
|
virtualController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout);
|
||||||
timerLongClick.schedule(longClickTimerTask, timerLongClickTimeout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onLongClickCallback() {
|
private void onLongClickCallback() {
|
||||||
@@ -198,12 +189,7 @@ public class DigitalButton extends VirtualControllerElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We may be called for a release without a prior click
|
// We may be called for a release without a prior click
|
||||||
if (timerLongClick != null) {
|
virtualController.getHandler().removeCallbacks(longClickRunnable);
|
||||||
timerLongClick.cancel();
|
|
||||||
}
|
|
||||||
if (longClickTimerTask != null) {
|
|
||||||
longClickTimerTask.cancel();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -214,8 +200,7 @@ public class DigitalButton extends VirtualControllerElement {
|
|||||||
int action = event.getActionMasked();
|
int action = event.getActionMasked();
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case MotionEvent.ACTION_DOWN:
|
case MotionEvent.ACTION_DOWN: {
|
||||||
case MotionEvent.ACTION_POINTER_DOWN: {
|
|
||||||
movingButton = null;
|
movingButton = null;
|
||||||
setPressed(true);
|
setPressed(true);
|
||||||
onClickCallback();
|
onClickCallback();
|
||||||
@@ -230,8 +215,7 @@ public class DigitalButton extends VirtualControllerElement {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case MotionEvent.ACTION_CANCEL:
|
case MotionEvent.ACTION_CANCEL:
|
||||||
case MotionEvent.ACTION_UP:
|
case MotionEvent.ACTION_UP: {
|
||||||
case MotionEvent.ACTION_POINTER_UP: {
|
|
||||||
setPressed(false);
|
setPressed(false);
|
||||||
onReleaseCallback();
|
onReleaseCallback();
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ public class DigitalPad extends VirtualControllerElement {
|
|||||||
// get masked (not specific to a pointer) action
|
// get masked (not specific to a pointer) action
|
||||||
switch (event.getActionMasked()) {
|
switch (event.getActionMasked()) {
|
||||||
case MotionEvent.ACTION_DOWN:
|
case MotionEvent.ACTION_DOWN:
|
||||||
case MotionEvent.ACTION_POINTER_DOWN:
|
|
||||||
case MotionEvent.ACTION_MOVE: {
|
case MotionEvent.ACTION_MOVE: {
|
||||||
direction = 0;
|
direction = 0;
|
||||||
|
|
||||||
@@ -184,8 +183,7 @@ public class DigitalPad extends VirtualControllerElement {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case MotionEvent.ACTION_CANCEL:
|
case MotionEvent.ACTION_CANCEL:
|
||||||
case MotionEvent.ACTION_UP:
|
case MotionEvent.ACTION_UP: {
|
||||||
case MotionEvent.ACTION_POINTER_UP: {
|
|
||||||
direction = 0;
|
direction = 0;
|
||||||
newDirectionCallback(direction);
|
newDirectionCallback(direction);
|
||||||
invalidate();
|
invalidate();
|
||||||
|
|||||||
+53
-39
@@ -5,24 +5,23 @@
|
|||||||
package com.limelight.binding.input.virtual_controller;
|
package com.limelight.binding.input.virtual_controller;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
import android.widget.RelativeLayout;
|
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.limelight.LimeLog;
|
||||||
import com.limelight.R;
|
import com.limelight.R;
|
||||||
import com.limelight.binding.input.ControllerHandler;
|
import com.limelight.binding.input.ControllerHandler;
|
||||||
import com.limelight.nvstream.NvConnection;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Timer;
|
|
||||||
import java.util.TimerTask;
|
|
||||||
|
|
||||||
public class VirtualController {
|
public class VirtualController {
|
||||||
public class ControllerInputContext {
|
public static class ControllerInputContext {
|
||||||
public short inputMap = 0x0000;
|
public short inputMap = 0x0000;
|
||||||
public byte leftTrigger = 0x00;
|
public byte leftTrigger = 0x00;
|
||||||
public byte rightTrigger = 0x00;
|
public byte rightTrigger = 0x00;
|
||||||
@@ -40,13 +39,18 @@ public class VirtualController {
|
|||||||
|
|
||||||
private static final boolean _PRINT_DEBUG_INFORMATION = false;
|
private static final boolean _PRINT_DEBUG_INFORMATION = false;
|
||||||
|
|
||||||
private ControllerHandler controllerHandler;
|
private final ControllerHandler controllerHandler;
|
||||||
private Context context = null;
|
private final Context context;
|
||||||
|
private final Handler handler;
|
||||||
|
|
||||||
|
private final Runnable delayedRetransmitRunnable = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
sendControllerInputContextInternal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private FrameLayout frame_layout = null;
|
private FrameLayout frame_layout = null;
|
||||||
private RelativeLayout relative_layout = null;
|
|
||||||
|
|
||||||
private Timer retransmitTimer;
|
|
||||||
|
|
||||||
ControllerMode currentMode = ControllerMode.Active;
|
ControllerMode currentMode = ControllerMode.Active;
|
||||||
ControllerInputContext inputContext = new ControllerInputContext();
|
ControllerInputContext inputContext = new ControllerInputContext();
|
||||||
@@ -59,10 +63,7 @@ public class VirtualController {
|
|||||||
this.controllerHandler = controllerHandler;
|
this.controllerHandler = controllerHandler;
|
||||||
this.frame_layout = layout;
|
this.frame_layout = layout;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
this.handler = new Handler(Looper.getMainLooper());
|
||||||
relative_layout = new RelativeLayout(context);
|
|
||||||
|
|
||||||
frame_layout.addView(relative_layout);
|
|
||||||
|
|
||||||
buttonConfigure = new Button(context);
|
buttonConfigure = new Button(context);
|
||||||
buttonConfigure.setAlpha(0.25f);
|
buttonConfigure.setAlpha(0.25f);
|
||||||
@@ -87,7 +88,7 @@ public class VirtualController {
|
|||||||
|
|
||||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
|
||||||
|
|
||||||
relative_layout.invalidate();
|
buttonConfigure.invalidate();
|
||||||
|
|
||||||
for (VirtualControllerElement element : elements) {
|
for (VirtualControllerElement element : elements) {
|
||||||
element.invalidate();
|
element.invalidate();
|
||||||
@@ -97,32 +98,33 @@ public class VirtualController {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Handler getHandler() {
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
public void hide() {
|
public void hide() {
|
||||||
retransmitTimer.cancel();
|
for (VirtualControllerElement element : elements) {
|
||||||
relative_layout.setVisibility(View.INVISIBLE);
|
element.setVisibility(View.INVISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonConfigure.setVisibility(View.INVISIBLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void show() {
|
public void show() {
|
||||||
relative_layout.setVisibility(View.VISIBLE);
|
for (VirtualControllerElement element : elements) {
|
||||||
|
element.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
// HACK: GFE sometimes discards gamepad packets when they are received
|
buttonConfigure.setVisibility(View.VISIBLE);
|
||||||
// very shortly after another. This can be critical if an axis zeroing packet
|
|
||||||
// is lost and causes an analog stick to get stuck. To avoid this, we send
|
|
||||||
// a gamepad input packet every 100 ms to ensure any loss can be recovered.
|
|
||||||
retransmitTimer = new Timer("OSC timer", true);
|
|
||||||
retransmitTimer.schedule(new TimerTask() {
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
sendControllerInputContext();
|
|
||||||
}
|
|
||||||
}, 100, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void removeElements() {
|
public void removeElements() {
|
||||||
for (VirtualControllerElement element : elements) {
|
for (VirtualControllerElement element : elements) {
|
||||||
relative_layout.removeView(element);
|
frame_layout.removeView(element);
|
||||||
}
|
}
|
||||||
elements.clear();
|
elements.clear();
|
||||||
|
|
||||||
|
frame_layout.removeView(buttonConfigure);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setOpacity(int opacity) {
|
public void setOpacity(int opacity) {
|
||||||
@@ -134,10 +136,10 @@ public class VirtualController {
|
|||||||
|
|
||||||
public void addElement(VirtualControllerElement element, int x, int y, int width, int height) {
|
public void addElement(VirtualControllerElement element, int x, int y, int width, int height) {
|
||||||
elements.add(element);
|
elements.add(element);
|
||||||
RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(width, height);
|
FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(width, height);
|
||||||
layoutParams.setMargins(x, y, 0, 0);
|
layoutParams.setMargins(x, y, 0, 0);
|
||||||
|
|
||||||
relative_layout.addView(element, layoutParams);
|
frame_layout.addView(element, layoutParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<VirtualControllerElement> getElements() {
|
public List<VirtualControllerElement> getElements() {
|
||||||
@@ -146,23 +148,20 @@ public class VirtualController {
|
|||||||
|
|
||||||
private static final void _DBG(String text) {
|
private static final void _DBG(String text) {
|
||||||
if (_PRINT_DEBUG_INFORMATION) {
|
if (_PRINT_DEBUG_INFORMATION) {
|
||||||
System.out.println("VirtualController: " + text);
|
LimeLog.info("VirtualController: " + text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void refreshLayout() {
|
public void refreshLayout() {
|
||||||
relative_layout.removeAllViews();
|
|
||||||
removeElements();
|
removeElements();
|
||||||
|
|
||||||
DisplayMetrics screen = context.getResources().getDisplayMetrics();
|
DisplayMetrics screen = context.getResources().getDisplayMetrics();
|
||||||
|
|
||||||
int buttonSize = (int)(screen.heightPixels*0.06f);
|
int buttonSize = (int)(screen.heightPixels*0.06f);
|
||||||
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(buttonSize, buttonSize);
|
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(buttonSize, buttonSize);
|
||||||
params.addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE);
|
|
||||||
params.addRule(RelativeLayout.ALIGN_PARENT_TOP, RelativeLayout.TRUE);
|
|
||||||
params.leftMargin = 15;
|
params.leftMargin = 15;
|
||||||
params.topMargin = 15;
|
params.topMargin = 15;
|
||||||
relative_layout.addView(buttonConfigure, params);
|
frame_layout.addView(buttonConfigure, params);
|
||||||
|
|
||||||
// Start with the default layout
|
// Start with the default layout
|
||||||
VirtualControllerConfigurationLoader.createDefaultLayout(this, context);
|
VirtualControllerConfigurationLoader.createDefaultLayout(this, context);
|
||||||
@@ -179,7 +178,7 @@ public class VirtualController {
|
|||||||
return inputContext;
|
return inputContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendControllerInputContext() {
|
private void sendControllerInputContextInternal() {
|
||||||
_DBG("INPUT_MAP + " + inputContext.inputMap);
|
_DBG("INPUT_MAP + " + inputContext.inputMap);
|
||||||
_DBG("LEFT_TRIGGER " + inputContext.leftTrigger);
|
_DBG("LEFT_TRIGGER " + inputContext.leftTrigger);
|
||||||
_DBG("RIGHT_TRIGGER " + inputContext.rightTrigger);
|
_DBG("RIGHT_TRIGGER " + inputContext.rightTrigger);
|
||||||
@@ -198,4 +197,19 @@ public class VirtualController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void sendControllerInputContext() {
|
||||||
|
// Cancel retransmissions of prior gamepad inputs
|
||||||
|
handler.removeCallbacks(delayedRetransmitRunnable);
|
||||||
|
|
||||||
|
sendControllerInputContextInternal();
|
||||||
|
|
||||||
|
// HACK: GFE sometimes discards gamepad packets when they are received
|
||||||
|
// very shortly after another. This can be critical if an axis zeroing packet
|
||||||
|
// is lost and causes an analog stick to get stuck. To avoid this, we retransmit
|
||||||
|
// the gamepad state a few times unless another input event happens before then.
|
||||||
|
handler.postDelayed(delayedRetransmitRunnable, 25);
|
||||||
|
handler.postDelayed(delayedRetransmitRunnable, 50);
|
||||||
|
handler.postDelayed(delayedRetransmitRunnable, 75);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-17
@@ -40,27 +40,31 @@ public class VirtualControllerConfigurationLoader {
|
|||||||
VirtualController.ControllerInputContext inputContext =
|
VirtualController.ControllerInputContext inputContext =
|
||||||
controller.getControllerInputContext();
|
controller.getControllerInputContext();
|
||||||
|
|
||||||
if (direction == DigitalPad.DIGITAL_PAD_DIRECTION_NO_DIRECTION) {
|
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_LEFT) != 0) {
|
||||||
inputContext.inputMap &= ~ControllerPacket.LEFT_FLAG;
|
|
||||||
inputContext.inputMap &= ~ControllerPacket.RIGHT_FLAG;
|
|
||||||
inputContext.inputMap &= ~ControllerPacket.UP_FLAG;
|
|
||||||
inputContext.inputMap &= ~ControllerPacket.DOWN_FLAG;
|
|
||||||
|
|
||||||
controller.sendControllerInputContext();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_LEFT) > 0) {
|
|
||||||
inputContext.inputMap |= ControllerPacket.LEFT_FLAG;
|
inputContext.inputMap |= ControllerPacket.LEFT_FLAG;
|
||||||
}
|
}
|
||||||
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_RIGHT) > 0) {
|
else {
|
||||||
|
inputContext.inputMap &= ~ControllerPacket.LEFT_FLAG;
|
||||||
|
}
|
||||||
|
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_RIGHT) != 0) {
|
||||||
inputContext.inputMap |= ControllerPacket.RIGHT_FLAG;
|
inputContext.inputMap |= ControllerPacket.RIGHT_FLAG;
|
||||||
}
|
}
|
||||||
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_UP) > 0) {
|
else {
|
||||||
|
inputContext.inputMap &= ~ControllerPacket.RIGHT_FLAG;
|
||||||
|
}
|
||||||
|
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_UP) != 0) {
|
||||||
inputContext.inputMap |= ControllerPacket.UP_FLAG;
|
inputContext.inputMap |= ControllerPacket.UP_FLAG;
|
||||||
}
|
}
|
||||||
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_DOWN) > 0) {
|
else {
|
||||||
|
inputContext.inputMap &= ~ControllerPacket.UP_FLAG;
|
||||||
|
}
|
||||||
|
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_DOWN) != 0) {
|
||||||
inputContext.inputMap |= ControllerPacket.DOWN_FLAG;
|
inputContext.inputMap |= ControllerPacket.DOWN_FLAG;
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
inputContext.inputMap &= ~ControllerPacket.DOWN_FLAG;
|
||||||
|
}
|
||||||
|
|
||||||
controller.sendControllerInputContext();
|
controller.sendControllerInputContext();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -167,11 +171,11 @@ public class VirtualControllerConfigurationLoader {
|
|||||||
private static final int DPAD_BASE_Y = 41;
|
private static final int DPAD_BASE_Y = 41;
|
||||||
private static final int DPAD_SIZE = 30;
|
private static final int DPAD_SIZE = 30;
|
||||||
|
|
||||||
private static final int ANALOG_L_BASE_X = 4;
|
private static final int ANALOG_L_BASE_X = 6;
|
||||||
private static final int ANALOG_L_BASE_Y = 1;
|
private static final int ANALOG_L_BASE_Y = 4;
|
||||||
private static final int ANALOG_R_BASE_X = 96;
|
private static final int ANALOG_R_BASE_X = 98;
|
||||||
private static final int ANALOG_R_BASE_Y = 42;
|
private static final int ANALOG_R_BASE_Y = 42;
|
||||||
private static final int ANALOG_SIZE = 28;
|
private static final int ANALOG_SIZE = 26;
|
||||||
|
|
||||||
private static final int L3_R3_BASE_Y = 60;
|
private static final int L3_R3_BASE_Y = 60;
|
||||||
|
|
||||||
@@ -181,6 +185,10 @@ public class VirtualControllerConfigurationLoader {
|
|||||||
private static final int START_BACK_WIDTH = 12;
|
private static final int START_BACK_WIDTH = 12;
|
||||||
private static final int START_BACK_HEIGHT = 7;
|
private static final int START_BACK_HEIGHT = 7;
|
||||||
|
|
||||||
|
// Make the Guide Menu be in the center of START and BACK menu
|
||||||
|
private static final int GUIDE_X = START_X-BACK_X;
|
||||||
|
private static final int GUIDE_Y = START_BACK_Y;
|
||||||
|
|
||||||
public static void createDefaultLayout(final VirtualController controller, final Context context) {
|
public static void createDefaultLayout(final VirtualController controller, final Context context) {
|
||||||
|
|
||||||
DisplayMetrics screen = context.getResources().getDisplayMetrics();
|
DisplayMetrics screen = context.getResources().getDisplayMetrics();
|
||||||
@@ -329,6 +337,16 @@ public class VirtualControllerConfigurationLoader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(config.showGuideButton){
|
||||||
|
controller.addElement(createDigitalButton(VirtualControllerElement.EID_GDB,
|
||||||
|
ControllerPacket.SPECIAL_BUTTON_FLAG, 0, 1, "GUIDE", -1, controller, context),
|
||||||
|
screenScale(GUIDE_X, height)+ rightDisplacement,
|
||||||
|
screenScale(GUIDE_Y, height),
|
||||||
|
screenScale(START_BACK_WIDTH, height),
|
||||||
|
screenScale(START_BACK_HEIGHT, height)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
controller.setOpacity(config.oscOpacity);
|
controller.setOpacity(config.oscOpacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-9
@@ -12,7 +12,7 @@ import android.graphics.Paint;
|
|||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
import android.view.MotionEvent;
|
import android.view.MotionEvent;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.RelativeLayout;
|
import android.widget.FrameLayout;
|
||||||
|
|
||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
@@ -35,6 +35,7 @@ public abstract class VirtualControllerElement extends View {
|
|||||||
public static final int EID_RS = 13;
|
public static final int EID_RS = 13;
|
||||||
public static final int EID_LSB = 14;
|
public static final int EID_LSB = 14;
|
||||||
public static final int EID_RSB = 15;
|
public static final int EID_RSB = 15;
|
||||||
|
public static final int EID_GDB = 16;
|
||||||
|
|
||||||
protected VirtualController virtualController;
|
protected VirtualController virtualController;
|
||||||
protected final int elementId;
|
protected final int elementId;
|
||||||
@@ -72,7 +73,7 @@ public abstract class VirtualControllerElement extends View {
|
|||||||
int newPos_x = (int) getX() + x - pressed_x;
|
int newPos_x = (int) getX() + x - pressed_x;
|
||||||
int newPos_y = (int) getY() + y - pressed_y;
|
int newPos_y = (int) getY() + y - pressed_y;
|
||||||
|
|
||||||
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
|
FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
|
||||||
|
|
||||||
layoutParams.leftMargin = newPos_x > 0 ? newPos_x : 0;
|
layoutParams.leftMargin = newPos_x > 0 ? newPos_x : 0;
|
||||||
layoutParams.topMargin = newPos_y > 0 ? newPos_y : 0;
|
layoutParams.topMargin = newPos_y > 0 ? newPos_y : 0;
|
||||||
@@ -83,7 +84,7 @@ public abstract class VirtualControllerElement extends View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected void resizeElement(int pressed_x, int pressed_y, int width, int height) {
|
protected void resizeElement(int pressed_x, int pressed_y, int width, int height) {
|
||||||
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
|
FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
|
||||||
|
|
||||||
int newHeight = height + (startSize_y - pressed_y);
|
int newHeight = height + (startSize_y - pressed_y);
|
||||||
int newWidth = width + (startSize_x - pressed_x);
|
int newWidth = width + (startSize_x - pressed_x);
|
||||||
@@ -223,13 +224,21 @@ public abstract class VirtualControllerElement extends View {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onTouchEvent(MotionEvent event) {
|
public boolean onTouchEvent(MotionEvent event) {
|
||||||
|
// Ignore secondary touches on controls
|
||||||
|
//
|
||||||
|
// NB: We can get an additional pointer down if the user touches a non-StreamView area
|
||||||
|
// while also touching an OSC control, even if that pointer down doesn't correspond to
|
||||||
|
// an area of the OSC control.
|
||||||
|
if (event.getActionIndex() != 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (virtualController.getControllerMode() == VirtualController.ControllerMode.Active) {
|
if (virtualController.getControllerMode() == VirtualController.ControllerMode.Active) {
|
||||||
return onElementTouchEvent(event);
|
return onElementTouchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (event.getActionMasked()) {
|
switch (event.getActionMasked()) {
|
||||||
case MotionEvent.ACTION_DOWN:
|
case MotionEvent.ACTION_DOWN: {
|
||||||
case MotionEvent.ACTION_POINTER_DOWN: {
|
|
||||||
position_pressed_x = event.getX();
|
position_pressed_x = event.getX();
|
||||||
position_pressed_y = event.getY();
|
position_pressed_y = event.getY();
|
||||||
startSize_x = getWidth();
|
startSize_x = getWidth();
|
||||||
@@ -267,8 +276,7 @@ public abstract class VirtualControllerElement extends View {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case MotionEvent.ACTION_CANCEL:
|
case MotionEvent.ACTION_CANCEL:
|
||||||
case MotionEvent.ACTION_UP:
|
case MotionEvent.ACTION_UP: {
|
||||||
case MotionEvent.ACTION_POINTER_UP: {
|
|
||||||
actionCancel();
|
actionCancel();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -316,7 +324,7 @@ public abstract class VirtualControllerElement extends View {
|
|||||||
public JSONObject getConfiguration() throws JSONException {
|
public JSONObject getConfiguration() throws JSONException {
|
||||||
JSONObject configuration = new JSONObject();
|
JSONObject configuration = new JSONObject();
|
||||||
|
|
||||||
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
|
FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
|
||||||
|
|
||||||
configuration.put("LEFT", layoutParams.leftMargin);
|
configuration.put("LEFT", layoutParams.leftMargin);
|
||||||
configuration.put("TOP", layoutParams.topMargin);
|
configuration.put("TOP", layoutParams.topMargin);
|
||||||
@@ -327,7 +335,7 @@ public abstract class VirtualControllerElement extends View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void loadConfiguration(JSONObject configuration) throws JSONException {
|
public void loadConfiguration(JSONObject configuration) throws JSONException {
|
||||||
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
|
FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
|
||||||
|
|
||||||
layoutParams.leftMargin = configuration.getInt("LEFT");
|
layoutParams.leftMargin = configuration.getInt("LEFT");
|
||||||
layoutParams.topMargin = configuration.getInt("TOP");
|
layoutParams.topMargin = configuration.getInt("TOP");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,16 @@ import android.annotation.SuppressLint;
|
|||||||
import android.app.ActivityManager;
|
import android.app.ActivityManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.pm.ConfigurationInfo;
|
import android.content.pm.ConfigurationInfo;
|
||||||
|
import android.media.MediaCodec;
|
||||||
import android.media.MediaCodecInfo;
|
import android.media.MediaCodecInfo;
|
||||||
import android.media.MediaCodecList;
|
import android.media.MediaCodecList;
|
||||||
import android.media.MediaCodecInfo.CodecCapabilities;
|
import android.media.MediaCodecInfo.CodecCapabilities;
|
||||||
import android.media.MediaCodecInfo.CodecProfileLevel;
|
import android.media.MediaCodecInfo.CodecProfileLevel;
|
||||||
|
import android.media.MediaFormat;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
|
import com.limelight.preferences.PreferenceConfiguration;
|
||||||
|
|
||||||
public class MediaCodecHelper {
|
public class MediaCodecHelper {
|
||||||
|
|
||||||
@@ -29,22 +32,24 @@ public class MediaCodecHelper {
|
|||||||
private static final List<String> blacklistedDecoderPrefixes;
|
private static final List<String> blacklistedDecoderPrefixes;
|
||||||
private static final List<String> spsFixupBitstreamFixupDecoderPrefixes;
|
private static final List<String> spsFixupBitstreamFixupDecoderPrefixes;
|
||||||
private static final List<String> blacklistedAdaptivePlaybackPrefixes;
|
private static final List<String> blacklistedAdaptivePlaybackPrefixes;
|
||||||
private static final List<String> deprioritizedHevcDecoders;
|
|
||||||
private static final List<String> baselineProfileHackPrefixes;
|
private static final List<String> baselineProfileHackPrefixes;
|
||||||
private static final List<String> directSubmitPrefixes;
|
private static final List<String> directSubmitPrefixes;
|
||||||
private static final List<String> constrainedHighProfilePrefixes;
|
private static final List<String> constrainedHighProfilePrefixes;
|
||||||
private static final List<String> whitelistedHevcDecoders;
|
private static final List<String> whitelistedHevcDecoders;
|
||||||
private static final List<String> refFrameInvalidationAvcPrefixes;
|
private static final List<String> refFrameInvalidationAvcPrefixes;
|
||||||
private static final List<String> refFrameInvalidationHevcPrefixes;
|
private static final List<String> refFrameInvalidationHevcPrefixes;
|
||||||
private static final List<String> blacklisted49FpsDecoderPrefixes;
|
private static final List<String> useFourSlicesPrefixes;
|
||||||
private static final List<String> blacklisted59FpsDecoderPrefixes;
|
|
||||||
private static final List<String> qualcommDecoderPrefixes;
|
private static final List<String> qualcommDecoderPrefixes;
|
||||||
|
private static final List<String> kirinDecoderPrefixes;
|
||||||
|
private static final List<String> exynosDecoderPrefixes;
|
||||||
|
private static final List<String> amlogicDecoderPrefixes;
|
||||||
|
private static final List<String> knownVendorLowLatencyOptions;
|
||||||
|
|
||||||
// FIXME: Remove when Android R SDK is finalized
|
public static final boolean SHOULD_BYPASS_SOFTWARE_BLOCK =
|
||||||
public static final String FEATURE_LowLatency = "low-latency";
|
Build.HARDWARE.equals("ranchu") || Build.HARDWARE.equals("cheets") || Build.BRAND.equals("Android-x86");
|
||||||
public static final String KEY_LOW_LATENCY = "low-latency";
|
|
||||||
|
|
||||||
private static boolean isLowEndSnapdragon = false;
|
private static boolean isLowEndSnapdragon = false;
|
||||||
|
private static boolean isAdreno620 = false;
|
||||||
private static boolean initialized = false;
|
private static boolean initialized = false;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
@@ -67,7 +72,10 @@ public class MediaCodecHelper {
|
|||||||
|
|
||||||
static {
|
static {
|
||||||
refFrameInvalidationAvcPrefixes = new LinkedList<>();
|
refFrameInvalidationAvcPrefixes = new LinkedList<>();
|
||||||
|
|
||||||
refFrameInvalidationHevcPrefixes = new LinkedList<>();
|
refFrameInvalidationHevcPrefixes = new LinkedList<>();
|
||||||
|
refFrameInvalidationHevcPrefixes.add("omx.exynos");
|
||||||
|
refFrameInvalidationHevcPrefixes.add("c2.exynos");
|
||||||
|
|
||||||
// Qualcomm and NVIDIA may be added at runtime
|
// Qualcomm and NVIDIA may be added at runtime
|
||||||
}
|
}
|
||||||
@@ -79,18 +87,18 @@ public class MediaCodecHelper {
|
|||||||
static {
|
static {
|
||||||
blacklistedDecoderPrefixes = new LinkedList<>();
|
blacklistedDecoderPrefixes = new LinkedList<>();
|
||||||
|
|
||||||
// Blacklist software decoders that don't support H264 high profile,
|
// Blacklist software decoders that don't support H264 high profile except on systems
|
||||||
// but exclude the official AOSP and CrOS emulator from this restriction.
|
// that are expected to only have software decoders (like emulators).
|
||||||
if (!Build.HARDWARE.equals("ranchu") && !Build.HARDWARE.equals("cheets")) {
|
if (!SHOULD_BYPASS_SOFTWARE_BLOCK) {
|
||||||
blacklistedDecoderPrefixes.add("omx.google");
|
blacklistedDecoderPrefixes.add("omx.google");
|
||||||
blacklistedDecoderPrefixes.add("AVCDecoder");
|
blacklistedDecoderPrefixes.add("AVCDecoder");
|
||||||
}
|
|
||||||
|
|
||||||
// We want to avoid ffmpeg decoders since they're software decoders,
|
// We want to avoid ffmpeg decoders since they're usually software decoders,
|
||||||
// but on Android-x86 they might be all we have (and also relatively
|
// but we'll defer to the Android 10 isSoftwareOnly() API on newer devices
|
||||||
// performant on a modern x86 processor).
|
// to determine if we should use these or not.
|
||||||
if (!Build.BRAND.equals("Android-x86")) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
blacklistedDecoderPrefixes.add("OMX.ffmpeg");
|
blacklistedDecoderPrefixes.add("OMX.ffmpeg");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force these decoders disabled because:
|
// Force these decoders disabled because:
|
||||||
@@ -116,7 +124,7 @@ public class MediaCodecHelper {
|
|||||||
// if adaptive playback was enabled so let's avoid it to be safe.
|
// if adaptive playback was enabled so let's avoid it to be safe.
|
||||||
blacklistedAdaptivePlaybackPrefixes.add("omx.intel");
|
blacklistedAdaptivePlaybackPrefixes.add("omx.intel");
|
||||||
// The MediaTek decoder crashes at 1080p when adaptive playback is enabled
|
// The MediaTek decoder crashes at 1080p when adaptive playback is enabled
|
||||||
// on some Android TV devices with H.265 only.
|
// on some Android TV devices with HEVC only.
|
||||||
blacklistedAdaptivePlaybackPrefixes.add("omx.mtk");
|
blacklistedAdaptivePlaybackPrefixes.add("omx.mtk");
|
||||||
|
|
||||||
constrainedHighProfilePrefixes = new LinkedList<>();
|
constrainedHighProfilePrefixes = new LinkedList<>();
|
||||||
@@ -127,28 +135,33 @@ public class MediaCodecHelper {
|
|||||||
whitelistedHevcDecoders = new LinkedList<>();
|
whitelistedHevcDecoders = new LinkedList<>();
|
||||||
|
|
||||||
// Allow software HEVC decoding in the official AOSP emulator
|
// Allow software HEVC decoding in the official AOSP emulator
|
||||||
if (Build.HARDWARE.equals("ranchu") && Build.BRAND.equals("google")) {
|
if (Build.HARDWARE.equals("ranchu")) {
|
||||||
whitelistedHevcDecoders.add("omx.google");
|
whitelistedHevcDecoders.add("omx.google");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exynos seems to be the only HEVC decoder that works reliably
|
// Exynos seems to be the only HEVC decoder that works reliably
|
||||||
whitelistedHevcDecoders.add("omx.exynos");
|
whitelistedHevcDecoders.add("omx.exynos");
|
||||||
|
|
||||||
// On Darcy (Shield 2017), HEVC runs fine with no fixups required.
|
// On Darcy (Shield 2017), HEVC runs fine with no fixups required. For some reason,
|
||||||
// For some reason, other X1 implementations require bitstream fixups.
|
// other X1 implementations require bitstream fixups. However, since numReferenceFrames
|
||||||
if (Build.DEVICE.equalsIgnoreCase("darcy")) {
|
// has been supported in GFE since late 2017, we'll go ahead and enable HEVC for all
|
||||||
|
// device models.
|
||||||
|
//
|
||||||
|
// NVIDIA does partial HEVC acceleration on the Shield Tablet. I don't know
|
||||||
|
// whether the performance is good enough to use for streaming, but they're
|
||||||
|
// using the same omx.nvidia.h265.decode name as the Shield TV which has a
|
||||||
|
// fully accelerated HEVC pipeline. AFAIK, the only K1 devices with this
|
||||||
|
// partially accelerated HEVC decoder are the Shield Tablet and Xiaomi MiPad,
|
||||||
|
// so I'll check for those here.
|
||||||
|
//
|
||||||
|
// In case there are some that I missed, I will also exclude pre-Oreo OSes since
|
||||||
|
// only Shield ATV got an Oreo update and any newer Tegra devices will not ship
|
||||||
|
// with an old OS like Nougat.
|
||||||
|
if (!Build.DEVICE.equalsIgnoreCase("shieldtablet") &&
|
||||||
|
!Build.DEVICE.equalsIgnoreCase("mocha") &&
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
whitelistedHevcDecoders.add("omx.nvidia");
|
whitelistedHevcDecoders.add("omx.nvidia");
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
// TODO: This needs a similar fixup to the Tegra 3 otherwise it buffers 16 frames
|
|
||||||
}
|
|
||||||
|
|
||||||
// Older Sony ATVs (SVP-DTV15) have broken MediaTek codecs (decoder hangs after rendering the first frame).
|
|
||||||
// I know the Fire TV 2 and 3 works, so I'll whitelist Amazon devices which seem to actually be tested.
|
|
||||||
if (Build.MANUFACTURER.equalsIgnoreCase("Amazon")) {
|
|
||||||
whitelistedHevcDecoders.add("omx.mtk");
|
|
||||||
whitelistedHevcDecoders.add("omx.amlogic");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plot twist: On newer Sony devices (BRAVIA_ATV2, BRAVIA_ATV3_4K, BRAVIA_UR1_4K) the H.264 decoder crashes
|
// Plot twist: On newer Sony devices (BRAVIA_ATV2, BRAVIA_ATV3_4K, BRAVIA_UR1_4K) the H.264 decoder crashes
|
||||||
// on several configurations (> 60 FPS and 1440p) that work with HEVC, so we'll whitelist those devices for HEVC.
|
// on several configurations (> 60 FPS and 1440p) that work with HEVC, so we'll whitelist those devices for HEVC.
|
||||||
@@ -156,9 +169,30 @@ public class MediaCodecHelper {
|
|||||||
whitelistedHevcDecoders.add("omx.mtk");
|
whitelistedHevcDecoders.add("omx.mtk");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Amlogic requires 1 reference frame for HEVC to avoid hanging. Since it's been years
|
||||||
|
// since GFE added support for maxNumReferenceFrames, we'll just enable all Amlogic SoCs
|
||||||
|
// running Android 9 or later.
|
||||||
|
//
|
||||||
|
// NB: We don't do this on Sabrina (GCWGTV) because H.264 is lower latency when we use
|
||||||
|
// vendor.low-latency.enable. We will still use HEVC if decoderCanMeetPerformancePointWithHevcAndNotAvc()
|
||||||
|
// determines it's the only way to meet the performance requirements.
|
||||||
|
//
|
||||||
|
// With the Android 12 update, Sabrina now uses HEVC (with RFI) based upon FEATURE_LowLatency
|
||||||
|
// support, which provides equivalent latency to H.264 now.
|
||||||
|
//
|
||||||
|
// FIXME: Should we do this for all Amlogic S905X SoCs?
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !Build.DEVICE.equalsIgnoreCase("sabrina")) {
|
||||||
|
whitelistedHevcDecoders.add("omx.amlogic");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Realtek SoCs are used inside many Android TV devices and can only do 4K60 with HEVC.
|
||||||
|
// We'll enable those HEVC decoders by default and see if anything breaks.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
whitelistedHevcDecoders.add("omx.realtek");
|
||||||
|
}
|
||||||
|
|
||||||
// These theoretically have good HEVC decoding capabilities (potentially better than
|
// These theoretically have good HEVC decoding capabilities (potentially better than
|
||||||
// their AVC decoders), but haven't been tested enough
|
// their AVC decoders), but haven't been tested enough
|
||||||
//whitelistedHevcDecoders.add("omx.amlogic");
|
|
||||||
//whitelistedHevcDecoders.add("omx.rk");
|
//whitelistedHevcDecoders.add("omx.rk");
|
||||||
|
|
||||||
// Let's see if HEVC decoders are finally stable with C2
|
// Let's see if HEVC decoders are finally stable with C2
|
||||||
@@ -169,29 +203,24 @@ public class MediaCodecHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static {
|
static {
|
||||||
deprioritizedHevcDecoders = new LinkedList<>();
|
useFourSlicesPrefixes = new LinkedList<>();
|
||||||
|
|
||||||
// These are decoders that work but aren't used by default for various reasons.
|
// Software decoders will use 4 slices per frame to allow for slice multithreading
|
||||||
|
useFourSlicesPrefixes.add("omx.google");
|
||||||
|
useFourSlicesPrefixes.add("AVCDecoder");
|
||||||
|
useFourSlicesPrefixes.add("omx.ffmpeg");
|
||||||
|
useFourSlicesPrefixes.add("c2.android");
|
||||||
|
|
||||||
// Qualcomm is currently the only decoders in this group.
|
// Old Qualcomm decoders are detected at runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
static {
|
static {
|
||||||
blacklisted49FpsDecoderPrefixes = new LinkedList<>();
|
knownVendorLowLatencyOptions = new LinkedList<>();
|
||||||
blacklisted59FpsDecoderPrefixes = new LinkedList<>();
|
|
||||||
|
|
||||||
// We see a bunch of crashes on MediaTek Android TVs running
|
knownVendorLowLatencyOptions.add("vendor.qti-ext-dec-low-latency.enable");
|
||||||
// at 49 FPS (PAL 50 Hz - 1). Blacklist this frame rate for
|
knownVendorLowLatencyOptions.add("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req");
|
||||||
// these devices and hope they fix it in Pie.
|
knownVendorLowLatencyOptions.add("vendor.rtc-ext-dec-low-latency.enable");
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
knownVendorLowLatencyOptions.add("vendor.low-latency.enable");
|
||||||
blacklisted49FpsDecoderPrefixes.add("omx.mtk");
|
|
||||||
|
|
||||||
// 59 FPS also seems to crash on the Sony Bravia TV ATV3 model.
|
|
||||||
// Blacklist that frame rate on these devices too.
|
|
||||||
if (Build.DEVICE.startsWith("BRAVIA_ATV3")) {
|
|
||||||
blacklisted59FpsDecoderPrefixes.add("omx.mtk");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static {
|
static {
|
||||||
@@ -201,6 +230,27 @@ public class MediaCodecHelper {
|
|||||||
qualcommDecoderPrefixes.add("c2.qti");
|
qualcommDecoderPrefixes.add("c2.qti");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
kirinDecoderPrefixes = new LinkedList<>();
|
||||||
|
|
||||||
|
kirinDecoderPrefixes.add("omx.hisi");
|
||||||
|
kirinDecoderPrefixes.add("c2.hisi"); // Unconfirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
exynosDecoderPrefixes = new LinkedList<>();
|
||||||
|
|
||||||
|
exynosDecoderPrefixes.add("omx.exynos");
|
||||||
|
exynosDecoderPrefixes.add("c2.exynos");
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
amlogicDecoderPrefixes = new LinkedList<>();
|
||||||
|
|
||||||
|
amlogicDecoderPrefixes.add("omx.amlogic");
|
||||||
|
amlogicDecoderPrefixes.add("c2.amlogic"); // Unconfirmed
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isPowerVR(String glRenderer) {
|
private static boolean isPowerVR(String glRenderer) {
|
||||||
return glRenderer.toLowerCase().contains("powervr");
|
return glRenderer.toLowerCase().contains("powervr");
|
||||||
}
|
}
|
||||||
@@ -235,19 +285,23 @@ public class MediaCodecHelper {
|
|||||||
return modelNumber.charAt(1) == '0';
|
return modelNumber.charAt(1) == '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int getAdrenoRendererModelNumber(String glRenderer) {
|
||||||
|
String modelNumber = getAdrenoVersionString(glRenderer);
|
||||||
|
if (modelNumber == null) {
|
||||||
|
// Not an Adreno GPU
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Integer.parseInt(modelNumber);
|
||||||
|
}
|
||||||
|
|
||||||
// This is a workaround for some broken devices that report
|
// This is a workaround for some broken devices that report
|
||||||
// only GLES 3.0 even though the GPU is an Adreno 4xx series part.
|
// only GLES 3.0 even though the GPU is an Adreno 4xx series part.
|
||||||
// An example of such a device is the Huawei Honor 5x with the
|
// An example of such a device is the Huawei Honor 5x with the
|
||||||
// Snapdragon 616 SoC (Adreno 405).
|
// Snapdragon 616 SoC (Adreno 405).
|
||||||
private static boolean isGLES31SnapdragonRenderer(String glRenderer) {
|
private static boolean isGLES31SnapdragonRenderer(String glRenderer) {
|
||||||
String modelNumber = getAdrenoVersionString(glRenderer);
|
|
||||||
if (modelNumber == null) {
|
|
||||||
// Not an Adreno GPU
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapdragon 4xx and higher support GLES 3.1
|
// Snapdragon 4xx and higher support GLES 3.1
|
||||||
return modelNumber.charAt(0) >= '4';
|
return getAdrenoRendererModelNumber(glRenderer) >= 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void initialize(Context context, String glRenderer) {
|
public static void initialize(Context context, String glRenderer) {
|
||||||
@@ -255,6 +309,40 @@ public class MediaCodecHelper {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older Sony ATVs (SVP-DTV15) have broken MediaTek codecs (decoder hangs after rendering the first frame).
|
||||||
|
// I know the Fire TV 2 and 3 works, so I'll whitelist Amazon devices which seem to actually be tested.
|
||||||
|
// We still have to check Build.MANUFACTURER to catch Amazon Fire tablets.
|
||||||
|
if (context.getPackageManager().hasSystemFeature("amazon.hardware.fire_tv") ||
|
||||||
|
Build.MANUFACTURER.equalsIgnoreCase("Amazon")) {
|
||||||
|
// HEVC and RFI have been confirmed working on Fire TV 2, Fire TV Stick 2, Fire TV 4K Max,
|
||||||
|
// Fire HD 8 2020, and Fire HD 8 2022 models.
|
||||||
|
//
|
||||||
|
// This is probably a good enough sample to conclude that all MediaTek Fire OS devices
|
||||||
|
// are likely to be okay.
|
||||||
|
whitelistedHevcDecoders.add("omx.mtk");
|
||||||
|
refFrameInvalidationHevcPrefixes.add("omx.mtk");
|
||||||
|
refFrameInvalidationHevcPrefixes.add("c2.mtk");
|
||||||
|
|
||||||
|
// This requires setting vdec-lowlatency on the Fire TV 3, otherwise the decoder
|
||||||
|
// never produces any output frames. See comment above for details on why we only
|
||||||
|
// do this for Fire TV devices.
|
||||||
|
whitelistedHevcDecoders.add("omx.amlogic");
|
||||||
|
|
||||||
|
// Fire TV 3 seems to produce random artifacts on HEVC streams after packet loss.
|
||||||
|
// Enabling RFI turns these artifacts into full decoder output hangs, so let's not enable
|
||||||
|
// that for Fire OS 6 Amlogic devices. We will leave HEVC enabled because that's the only
|
||||||
|
// way these devices can hit 4K. Hopefully this is just a problem with the BSP used in
|
||||||
|
// the Fire OS 6 Amlogic devices, so we will leave this enabled for Fire OS 7+.
|
||||||
|
//
|
||||||
|
// Apart from a few TV models, the main Amlogic-based Fire TV devices are the Fire TV
|
||||||
|
// Cubes and Fire TV 3. This check will exclude the Fire TV 3 and Fire TV Cube 1, but
|
||||||
|
// allow the newer Fire TV Cubes to use HEVC RFI.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
refFrameInvalidationHevcPrefixes.add("omx.amlogic");
|
||||||
|
refFrameInvalidationHevcPrefixes.add("c2.amlogic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ActivityManager activityManager =
|
ActivityManager activityManager =
|
||||||
(ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
|
(ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
|
||||||
ConfigurationInfo configInfo = activityManager.getDeviceConfigurationInfo();
|
ConfigurationInfo configInfo = activityManager.getDeviceConfigurationInfo();
|
||||||
@@ -262,22 +350,28 @@ public class MediaCodecHelper {
|
|||||||
LimeLog.info("OpenGL ES version: "+configInfo.reqGlEsVersion);
|
LimeLog.info("OpenGL ES version: "+configInfo.reqGlEsVersion);
|
||||||
|
|
||||||
isLowEndSnapdragon = isLowEndSnapdragonRenderer(glRenderer);
|
isLowEndSnapdragon = isLowEndSnapdragonRenderer(glRenderer);
|
||||||
|
isAdreno620 = getAdrenoRendererModelNumber(glRenderer) == 620;
|
||||||
|
|
||||||
// Tegra K1 and later can do reference frame invalidation properly
|
// Tegra K1 and later can do reference frame invalidation properly
|
||||||
if (configInfo.reqGlEsVersion >= 0x30000) {
|
if (configInfo.reqGlEsVersion >= 0x30000) {
|
||||||
LimeLog.info("Added omx.nvidia to AVC reference frame invalidation support list");
|
LimeLog.info("Added omx.nvidia/c2.nvidia to reference frame invalidation support list");
|
||||||
refFrameInvalidationAvcPrefixes.add("omx.nvidia");
|
refFrameInvalidationAvcPrefixes.add("omx.nvidia");
|
||||||
|
|
||||||
LimeLog.info("Added omx.qcom/c2.qti to AVC reference frame invalidation support list");
|
// Exclude HEVC RFI on Pixel C and Tegra devices prior to Android 11. Misbehaving RFI
|
||||||
refFrameInvalidationAvcPrefixes.add("omx.qcom");
|
// on these devices can cause hundreds of milliseconds of latency, so it's not worth
|
||||||
refFrameInvalidationAvcPrefixes.add("c2.qti");
|
// using it unless we're absolutely sure that it will not cause increased latency.
|
||||||
|
if (!Build.DEVICE.equalsIgnoreCase("dragon") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
// Prior to M, we were tricking the decoder into using baseline profile, which
|
refFrameInvalidationHevcPrefixes.add("omx.nvidia");
|
||||||
// won't support RFI properly.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
LimeLog.info("Added omx.intel to AVC reference frame invalidation support list");
|
|
||||||
refFrameInvalidationAvcPrefixes.add("omx.intel");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refFrameInvalidationAvcPrefixes.add("c2.nvidia"); // Unconfirmed
|
||||||
|
refFrameInvalidationHevcPrefixes.add("c2.nvidia"); // Unconfirmed
|
||||||
|
|
||||||
|
LimeLog.info("Added omx.qcom/c2.qti to reference frame invalidation support list");
|
||||||
|
refFrameInvalidationAvcPrefixes.add("omx.qcom");
|
||||||
|
refFrameInvalidationHevcPrefixes.add("omx.qcom");
|
||||||
|
refFrameInvalidationAvcPrefixes.add("c2.qti");
|
||||||
|
refFrameInvalidationHevcPrefixes.add("c2.qti");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Qualcomm's early HEVC decoders break hard on our HEVC stream. The best check to
|
// Qualcomm's early HEVC decoders break hard on our HEVC stream. The best check to
|
||||||
@@ -289,16 +383,15 @@ public class MediaCodecHelper {
|
|||||||
// (see comment on isGLES31SnapdragonRenderer).
|
// (see comment on isGLES31SnapdragonRenderer).
|
||||||
//
|
//
|
||||||
if (isGLES31SnapdragonRenderer(glRenderer)) {
|
if (isGLES31SnapdragonRenderer(glRenderer)) {
|
||||||
// We prefer reference frame invalidation support (which is only doable on AVC on
|
LimeLog.info("Added omx.qcom/c2.qti to HEVC decoders based on GLES 3.1+ support");
|
||||||
// older Qualcomm chips) vs. enabling HEVC by default. The user can override using the settings
|
whitelistedHevcDecoders.add("omx.qcom");
|
||||||
// to force HEVC on. If HDR or mobile data will be used, we'll override this and use
|
whitelistedHevcDecoders.add("c2.qti");
|
||||||
// HEVC anyway.
|
|
||||||
LimeLog.info("Added omx.qcom/c2.qti to deprioritized HEVC decoders based on GLES 3.1+ support");
|
|
||||||
deprioritizedHevcDecoders.add("omx.qcom");
|
|
||||||
deprioritizedHevcDecoders.add("c2.qti");
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevc");
|
blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevc");
|
||||||
|
|
||||||
|
// These older decoders need 4 slices per frame for best performance
|
||||||
|
useFourSlicesPrefixes.add("omx.qcom");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Older MediaTek SoCs have issues with HEVC rendering but the newer chips with
|
// Older MediaTek SoCs have issues with HEVC rendering but the newer chips with
|
||||||
@@ -312,8 +405,9 @@ public class MediaCodecHelper {
|
|||||||
// decoder hangs on the newer GE8100, GE8300, and GE8320 GPUs, so we limit it to the
|
// decoder hangs on the newer GE8100, GE8300, and GE8320 GPUs, so we limit it to the
|
||||||
// Series6XT GPUs where we know it works.
|
// Series6XT GPUs where we know it works.
|
||||||
if (glRenderer.contains("GX6")) {
|
if (glRenderer.contains("GX6")) {
|
||||||
LimeLog.info("Added omx.mtk to RFI list for HEVC");
|
LimeLog.info("Added omx.mtk/c2.mtk to RFI list for HEVC");
|
||||||
refFrameInvalidationHevcPrefixes.add("omx.mtk");
|
refFrameInvalidationHevcPrefixes.add("omx.mtk");
|
||||||
|
refFrameInvalidationHevcPrefixes.add("c2.mtk");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,15 +432,10 @@ public class MediaCodecHelper {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static long getMonotonicMillis() {
|
private static boolean decoderSupportsAndroidRLowLatency(MediaCodecInfo decoderInfo, String mimeType) {
|
||||||
return System.nanoTime() / 1000000L;
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean decoderSupportsLowLatency(MediaCodecInfo decoderInfo, String mimeType) {
|
|
||||||
// KitKat added CodecCapabilities.isFeatureSupported()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
|
||||||
try {
|
try {
|
||||||
if (decoderInfo.getCapabilitiesForType(mimeType).isFeatureSupported(FEATURE_LowLatency)) {
|
if (decoderInfo.getCapabilitiesForType(mimeType).isFeatureSupported(CodecCapabilities.FEATURE_LowLatency)) {
|
||||||
LimeLog.info("Low latency decoding mode supported (FEATURE_LowLatency)");
|
LimeLog.info("Low latency decoding mode supported (FEATURE_LowLatency)");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -359,36 +448,188 @@ public class MediaCodecHelper {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean decoderSupportsAdaptivePlayback(MediaCodecInfo decoderInfo, String mimeType) {
|
private static boolean decoderSupportsKnownVendorLowLatencyOption(String decoderName) {
|
||||||
// Possibly enable adaptive playback on KitKat and above
|
// It's only possible to probe vendor parameters on Android 12 and above.
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
if (isDecoderInList(blacklistedAdaptivePlaybackPrefixes, decoderInfo.getName())) {
|
MediaCodec testCodec = null;
|
||||||
LimeLog.info("Decoder blacklisted for adaptive playback");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (decoderInfo.getCapabilitiesForType(mimeType).
|
// Unfortunately we have to create an actual codec instance to get supported options.
|
||||||
isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback))
|
testCodec = MediaCodec.createByCodecName(decoderName);
|
||||||
{
|
|
||||||
// This will make getCapabilities() return that adaptive playback is supported
|
// See if any of the vendor parameters match ones we know about
|
||||||
LimeLog.info("Adaptive playback supported (FEATURE_AdaptivePlayback)");
|
for (String supportedOption : testCodec.getSupportedVendorParameters()) {
|
||||||
return true;
|
for (String knownLowLatencyOption : knownVendorLowLatencyOptions) {
|
||||||
|
if (supportedOption.equalsIgnoreCase(knownLowLatencyOption)) {
|
||||||
|
LimeLog.info(decoderName + " supports known low latency option: " + supportedOption);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Tolerate buggy codecs
|
// Tolerate buggy codecs
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
|
} finally {
|
||||||
|
if (testCodec != null) {
|
||||||
|
testCodec.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean decoderSupportsQcomVendorLowLatency(String decoderName) {
|
private static boolean decoderSupportsMaxOperatingRate(String decoderName) {
|
||||||
|
// Operate at maximum rate to lower latency as much as possible on
|
||||||
|
// some Qualcomm platforms. We could also set KEY_PRIORITY to 0 (realtime)
|
||||||
|
// but that will actually result in the decoder crashing if it can't satisfy
|
||||||
|
// our (ludicrous) operating rate requirement. This seems to cause reliable
|
||||||
|
// crashes on the Xiaomi Mi 10 lite 5G and Redmi K30i 5G on Android 10, so
|
||||||
|
// we'll disable it on Snapdragon 765G and all non-Qualcomm devices to be safe.
|
||||||
|
//
|
||||||
|
// NB: Even on Android 10, this optimization still provides significant
|
||||||
|
// performance gains on Pixel 2.
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
||||||
|
isDecoderInList(qualcommDecoderPrefixes, decoderName) &&
|
||||||
|
!isAdreno620;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean setDecoderLowLatencyOptions(MediaFormat videoFormat, MediaCodecInfo decoderInfo, int tryNumber) {
|
||||||
|
// Options here should be tried in the order of most to least risky. The decoder will use
|
||||||
|
// the first MediaFormat that doesn't fail in configure().
|
||||||
|
|
||||||
|
boolean setNewOption = false;
|
||||||
|
|
||||||
|
if (tryNumber < 1) {
|
||||||
|
// Official Android 11+ low latency option (KEY_LOW_LATENCY).
|
||||||
|
videoFormat.setInteger("low-latency", 1);
|
||||||
|
setNewOption = true;
|
||||||
|
|
||||||
|
// If this decoder officially supports FEATURE_LowLatency, we will just use that alone
|
||||||
|
// for try 0. Otherwise, we'll include it as best effort with other options.
|
||||||
|
if (decoderSupportsAndroidRLowLatency(decoderInfo, videoFormat.getString(MediaFormat.KEY_MIME))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tryNumber < 2 &&
|
||||||
|
(!Build.MANUFACTURER.equalsIgnoreCase("xiaomi") || Build.VERSION.SDK_INT > Build.VERSION_CODES.M)) {
|
||||||
|
// MediaTek decoders don't use vendor-defined keys for low latency mode. Instead, they have a modified
|
||||||
|
// version of AOSP's ACodec.cpp which supports the "vdec-lowlatency" option. This option is passed down
|
||||||
|
// to the decoder as OMX.MTK.index.param.video.LowLatencyDecode.
|
||||||
|
//
|
||||||
|
// This option is also plumbed for Amazon Amlogic-based devices like the Fire TV 3. Not only does it
|
||||||
|
// reduce latency on Amlogic, it fixes the HEVC bug that causes the decoder to not output any frames.
|
||||||
|
// Unfortunately, it does the exact opposite for the Xiaomi MITV4-ANSM0, breaking it in the way that
|
||||||
|
// Fire TV was broken prior to vdec-lowlatency :(
|
||||||
|
//
|
||||||
|
// On Fire TV 3, vdec-lowlatency is translated to OMX.amazon.fireos.index.video.lowLatencyDecode.
|
||||||
|
//
|
||||||
|
// https://github.com/yuan1617/Framwork/blob/master/frameworks/av/media/libstagefright/ACodec.cpp
|
||||||
|
// https://github.com/iykex/vendor_mediatek_proprietary_hardware/blob/master/libomx/video/MtkOmxVdecEx/MtkOmxVdecEx.h
|
||||||
|
videoFormat.setInteger("vdec-lowlatency", 1);
|
||||||
|
setNewOption = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tryNumber < 3) {
|
||||||
|
if (MediaCodecHelper.decoderSupportsMaxOperatingRate(decoderInfo.getName())) {
|
||||||
|
videoFormat.setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE);
|
||||||
|
setNewOption = true;
|
||||||
|
}
|
||||||
|
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
videoFormat.setInteger(MediaFormat.KEY_PRIORITY, 0);
|
||||||
|
setNewOption = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaCodec supports vendor-defined format keys using the "vendor.<extension name>.<parameter name>" syntax.
|
||||||
|
// These allow access to functionality that is not exposed through documented MediaFormat.KEY_* values.
|
||||||
|
// https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/common/inc/vidc_vendor_extensions.h;l=67
|
||||||
|
//
|
||||||
// MediaCodec vendor extension support was introduced in Android 8.0:
|
// MediaCodec vendor extension support was introduced in Android 8.0:
|
||||||
// https://cs.android.com/android/_/android/platform/frameworks/av/+/01c10f8cdcd58d1e7025f426a72e6e75ba5d7fc2
|
// https://cs.android.com/android/_/android/platform/frameworks/av/+/01c10f8cdcd58d1e7025f426a72e6e75ba5d7fc2
|
||||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
isDecoderInList(qualcommDecoderPrefixes, decoderName);
|
// Try vendor-specific low latency options
|
||||||
|
//
|
||||||
|
// NOTE: Update knownVendorLowLatencyOptions if you modify this code!
|
||||||
|
if (isDecoderInList(qualcommDecoderPrefixes, decoderInfo.getName())) {
|
||||||
|
// Examples of Qualcomm's vendor extensions for Snapdragon 845:
|
||||||
|
// https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/vdec/src/omx_vdec_extensions.hpp
|
||||||
|
// https://cs.android.com/android/_/android/platform/hardware/qcom/sm8150/media/+/0621ceb1c1b19564999db8293574a0e12952ff6c
|
||||||
|
//
|
||||||
|
// We will first try both, then try vendor.qti-ext-dec-low-latency.enable alone if that fails
|
||||||
|
if (tryNumber < 4) {
|
||||||
|
videoFormat.setInteger("vendor.qti-ext-dec-picture-order.enable", 1);
|
||||||
|
setNewOption = true;
|
||||||
|
}
|
||||||
|
if (tryNumber < 5) {
|
||||||
|
videoFormat.setInteger("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||||
|
setNewOption = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (isDecoderInList(kirinDecoderPrefixes, decoderInfo.getName())) {
|
||||||
|
if (tryNumber < 4) {
|
||||||
|
// Kirin low latency options
|
||||||
|
// https://developer.huawei.com/consumer/cn/forum/topic/0202325564295980115
|
||||||
|
videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req", 1);
|
||||||
|
videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy", -1);
|
||||||
|
setNewOption = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (isDecoderInList(exynosDecoderPrefixes, decoderInfo.getName())) {
|
||||||
|
if (tryNumber < 4) {
|
||||||
|
// Exynos low latency option for H.264 decoder
|
||||||
|
videoFormat.setInteger("vendor.rtc-ext-dec-low-latency.enable", 1);
|
||||||
|
setNewOption = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (isDecoderInList(amlogicDecoderPrefixes, decoderInfo.getName())) {
|
||||||
|
if (tryNumber < 4) {
|
||||||
|
// Amlogic low latency vendor extension
|
||||||
|
// https://github.com/codewalkerster/android_vendor_amlogic_common_prebuilt_libstagefrighthw/commit/41fefc4e035c476d58491324a5fe7666bfc2989e
|
||||||
|
videoFormat.setInteger("vendor.low-latency.enable", 1);
|
||||||
|
setNewOption = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return setNewOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean decoderSupportsFusedIdrFrame(MediaCodecInfo decoderInfo, String mimeType) {
|
||||||
|
// If adaptive playback is supported, we can submit new CSD together with a keyframe
|
||||||
|
try {
|
||||||
|
if (decoderInfo.getCapabilitiesForType(mimeType).
|
||||||
|
isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback)) {
|
||||||
|
LimeLog.info("Decoder supports fused IDR frames (FEATURE_AdaptivePlayback)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Tolerate buggy codecs
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean decoderSupportsAdaptivePlayback(MediaCodecInfo decoderInfo, String mimeType) {
|
||||||
|
if (isDecoderInList(blacklistedAdaptivePlaybackPrefixes, decoderInfo.getName())) {
|
||||||
|
LimeLog.info("Decoder blacklisted for adaptive playback");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (decoderInfo.getCapabilitiesForType(mimeType).
|
||||||
|
isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback))
|
||||||
|
{
|
||||||
|
// This will make getCapabilities() return that adaptive playback is supported
|
||||||
|
LimeLog.info("Adaptive playback supported (FEATURE_AdaptivePlayback)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Tolerate buggy codecs
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean decoderNeedsConstrainedHighProfile(String decoderName) {
|
public static boolean decoderNeedsConstrainedHighProfile(String decoderName) {
|
||||||
@@ -407,15 +648,14 @@ public class MediaCodecHelper {
|
|||||||
return isDecoderInList(baselineProfileHackPrefixes, decoderName);
|
return isDecoderInList(baselineProfileHackPrefixes, decoderName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean decoderBlacklistedForFrameRate(String decoderName, int fps) {
|
public static byte getDecoderOptimalSlicesPerFrame(String decoderName) {
|
||||||
if (fps == 49) {
|
if (isDecoderInList(useFourSlicesPrefixes, decoderName)) {
|
||||||
return isDecoderInList(blacklisted49FpsDecoderPrefixes, decoderName);
|
// 4 slices per frame reduces decoding latency on older Qualcomm devices
|
||||||
}
|
return 4;
|
||||||
else if (fps == 59) {
|
|
||||||
return isDecoderInList(blacklisted59FpsDecoderPrefixes, decoderName);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return false;
|
// 1 slice per frame produces the optimal encoding efficiency
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,29 +674,77 @@ public class MediaCodecHelper {
|
|||||||
return isDecoderInList(refFrameInvalidationAvcPrefixes, decoderName);
|
return isDecoderInList(refFrameInvalidationAvcPrefixes, decoderName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean decoderSupportsRefFrameInvalidationHevc(String decoderName) {
|
public static boolean decoderSupportsRefFrameInvalidationHevc(MediaCodecInfo decoderInfo) {
|
||||||
return isDecoderInList(refFrameInvalidationHevcPrefixes, decoderName);
|
// HEVC decoders seem to universally support RFI, but it can have huge latency penalties
|
||||||
|
// for some decoders due to the number of references frames being > 1. Old Amlogic
|
||||||
|
// decoders are known to have this problem.
|
||||||
|
//
|
||||||
|
// If the decoder supports FEATURE_LowLatency or any vendor low latency option,
|
||||||
|
// we will use that as an indication that it can handle HEVC RFI without excessively
|
||||||
|
// buffering frames.
|
||||||
|
if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc") ||
|
||||||
|
decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) {
|
||||||
|
LimeLog.info("Enabling HEVC RFI based on low latency option support");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDecoderInList(refFrameInvalidationHevcPrefixes, decoderInfo.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean decoderIsWhitelistedForHevc(String decoderName, boolean meteredData) {
|
public static boolean decoderSupportsRefFrameInvalidationAv1(MediaCodecInfo decoderInfo) {
|
||||||
// TODO: Shield Tablet K1/LTE?
|
// We'll use the same heuristics as HEVC for now
|
||||||
//
|
if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/av01") ||
|
||||||
// NVIDIA does partial HEVC acceleration on the Shield Tablet. I don't know
|
decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) {
|
||||||
// whether the performance is good enough to use for streaming, but they're
|
LimeLog.info("Enabling AV1 RFI based on low latency option support");
|
||||||
// using the same omx.nvidia.h265.decode name as the Shield TV which has a
|
return true;
|
||||||
// fully accelerated HEVC pipeline. AFAIK, the only K1 device with this
|
}
|
||||||
// partially accelerated HEVC decoder is the Shield Tablet, so I'll
|
|
||||||
// check for it here.
|
|
||||||
//
|
|
||||||
// TODO: Temporarily disabled with NVIDIA HEVC support
|
|
||||||
/*if (Build.DEVICE.equalsIgnoreCase("shieldtablet")) {
|
|
||||||
return false;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
// Google didn't have official support for HEVC (or more importantly, a CTS test) until
|
return false;
|
||||||
// Lollipop. I've seen some MediaTek devices on 4.4 crash when attempting to use HEVC,
|
}
|
||||||
// so I'm restricting HEVC usage to Lollipop and higher.
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
public static boolean decoderIsWhitelistedForHevc(MediaCodecInfo decoderInfo) {
|
||||||
|
//
|
||||||
|
// Software decoders are terrible and we never want to use them.
|
||||||
|
// We want to catch decoders like:
|
||||||
|
// OMX.qcom.video.decoder.hevcswvdec
|
||||||
|
// OMX.SEC.hevc.sw.dec
|
||||||
|
//
|
||||||
|
if (decoderInfo.getName().contains("sw")) {
|
||||||
|
LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly())) {
|
||||||
|
LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this device is media performance class 12 or higher, we will assume any hardware
|
||||||
|
// HEVC decoder present is fast and modern enough for streaming.
|
||||||
|
//
|
||||||
|
// [5.3/H-1-1] MUST NOT drop more than 2 frames in 10 seconds (i.e less than 0.333 percent frame drop) for a 1080p 60 fps video session under load.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
LimeLog.info("Media performance class: " + Build.VERSION.MEDIA_PERFORMANCE_CLASS);
|
||||||
|
if (Build.VERSION.MEDIA_PERFORMANCE_CLASS >= Build.VERSION_CODES.S) {
|
||||||
|
LimeLog.info("Allowing HEVC based on media performance class");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the decoder supports FEATURE_LowLatency, we will assume it is fast and modern enough
|
||||||
|
// to be preferable for streaming over H.264 decoders.
|
||||||
|
if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc")) {
|
||||||
|
LimeLog.info("Allowing HEVC based on FEATURE_LowLatency support");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, we use our list of known working HEVC decoders
|
||||||
|
return isDecoderInList(whitelistedHevcDecoders, decoderInfo.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isDecoderWhitelistedForAv1(MediaCodecInfo decoderInfo) {
|
||||||
|
// Google didn't have official support for AV1 (or more importantly, a CTS test) until
|
||||||
|
// Android 10, so don't use any decoder before then.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,42 +754,27 @@ public class MediaCodecHelper {
|
|||||||
// OMX.qcom.video.decoder.hevcswvdec
|
// OMX.qcom.video.decoder.hevcswvdec
|
||||||
// OMX.SEC.hevc.sw.dec
|
// OMX.SEC.hevc.sw.dec
|
||||||
//
|
//
|
||||||
if (decoderName.contains("sw")) {
|
if (decoderInfo.getName().contains("sw")) {
|
||||||
|
LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly()) {
|
||||||
|
LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some devices have HEVC decoders that we prefer not to use
|
// TODO: Test some AV1 decoders
|
||||||
// typically because it can't support reference frame invalidation.
|
return false;
|
||||||
// However, we will use it for HDR and for streaming over mobile networks
|
|
||||||
// since it works fine otherwise.
|
|
||||||
if (isDecoderInList(deprioritizedHevcDecoders, decoderName)) {
|
|
||||||
if (meteredData) {
|
|
||||||
LimeLog.info("Selected deprioritized decoder");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return isDecoderInList(whitelistedHevcDecoders, decoderName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
@SuppressWarnings("deprecation")
|
||||||
@SuppressLint("NewApi")
|
@SuppressLint("NewApi")
|
||||||
private static LinkedList<MediaCodecInfo> getMediaCodecList() {
|
private static LinkedList<MediaCodecInfo> getMediaCodecList() {
|
||||||
LinkedList<MediaCodecInfo> infoList = new LinkedList<>();
|
LinkedList<MediaCodecInfo> infoList = new LinkedList<>();
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
MediaCodecList mcl = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
|
||||||
MediaCodecList mcl = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
|
Collections.addAll(infoList, mcl.getCodecInfos());
|
||||||
Collections.addAll(infoList, mcl.getCodecInfos());
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
for (int i = 0; i < MediaCodecList.getCodecCount(); i++) {
|
|
||||||
infoList.add(MediaCodecList.getCodecInfoAt(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return infoList;
|
return infoList;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,13 +815,6 @@ public class MediaCodecHelper {
|
|||||||
if (codecInfo.isEncoder()) {
|
if (codecInfo.isEncoder()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip compatibility aliases on Q+
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
if (codecInfo.isAlias()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for preferred decoders
|
// Check for preferred decoders
|
||||||
if (preferredDecoder.equalsIgnoreCase(codecInfo.getName())) {
|
if (preferredDecoder.equalsIgnoreCase(codecInfo.getName())) {
|
||||||
@@ -564,7 +830,7 @@ public class MediaCodecHelper {
|
|||||||
private static boolean isCodecBlacklisted(MediaCodecInfo codecInfo) {
|
private static boolean isCodecBlacklisted(MediaCodecInfo codecInfo) {
|
||||||
// Use the new isSoftwareOnly() function on Android Q
|
// Use the new isSoftwareOnly() function on Android Q
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
if (codecInfo.isSoftwareOnly()) {
|
if (!SHOULD_BYPASS_SOFTWARE_BLOCK && codecInfo.isSoftwareOnly()) {
|
||||||
LimeLog.info("Skipping software-only decoder: "+codecInfo.getName());
|
LimeLog.info("Skipping software-only decoder: "+codecInfo.getName());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -634,43 +900,57 @@ public class MediaCodecHelper {
|
|||||||
// and we want to be sure all callers are handling this possibility
|
// and we want to be sure all callers are handling this possibility
|
||||||
@SuppressWarnings("RedundantThrows")
|
@SuppressWarnings("RedundantThrows")
|
||||||
private static MediaCodecInfo findKnownSafeDecoder(String mimeType, int requiredProfile) throws Exception {
|
private static MediaCodecInfo findKnownSafeDecoder(String mimeType, int requiredProfile) throws Exception {
|
||||||
for (MediaCodecInfo codecInfo : getMediaCodecList()) {
|
// Some devices (Exynos devces, at least) have two sets of decoders.
|
||||||
// Skip encoders
|
// The first set of decoders are C2 which do not support FEATURE_LowLatency,
|
||||||
if (codecInfo.isEncoder()) {
|
// but the second set of OMX decoders do support FEATURE_LowLatency. We want
|
||||||
continue;
|
// to pick the OMX decoders despite the fact that C2 is listed first.
|
||||||
}
|
// On some Qualcomm devices (like Pixel 4), there are separate low latency decoders
|
||||||
|
// (like c2.qti.hevc.decoder.low_latency) that advertise FEATURE_LowLatency while
|
||||||
// Skip compatibility aliases on Q+
|
// the standard ones (like c2.qti.hevc.decoder) do not. Like Exynos, the decoders
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
// with FEATURE_LowLatency support are listed after the standard ones.
|
||||||
if (codecInfo.isAlias()) {
|
for (int i = 0; i < 2; i++) {
|
||||||
|
for (MediaCodecInfo codecInfo : getMediaCodecList()) {
|
||||||
|
// Skip encoders
|
||||||
|
if (codecInfo.isEncoder()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Find a decoder that supports the requested video format
|
|
||||||
for (String mime : codecInfo.getSupportedTypes()) {
|
|
||||||
if (mime.equalsIgnoreCase(mimeType)) {
|
|
||||||
LimeLog.info("Examining decoder capabilities of "+codecInfo.getName());
|
|
||||||
|
|
||||||
// Skip blacklisted codecs
|
// Skip compatibility aliases on Q+
|
||||||
if (isCodecBlacklisted(codecInfo)) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
if (codecInfo.isAlias()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CodecCapabilities caps = codecInfo.getCapabilitiesForType(mime);
|
// Find a decoder that supports the requested video format
|
||||||
|
for (String mime : codecInfo.getSupportedTypes()) {
|
||||||
|
if (mime.equalsIgnoreCase(mimeType)) {
|
||||||
|
LimeLog.info("Examining decoder capabilities of " + codecInfo.getName() + " (round " + (i + 1) + ")");
|
||||||
|
|
||||||
if (requiredProfile != -1) {
|
// Skip blacklisted codecs
|
||||||
for (CodecProfileLevel profile : caps.profileLevels) {
|
if (isCodecBlacklisted(codecInfo)) {
|
||||||
if (profile.profile == requiredProfile) {
|
continue;
|
||||||
LimeLog.info("Decoder " + codecInfo.getName() + " supports required profile");
|
|
||||||
return codecInfo;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LimeLog.info("Decoder " + codecInfo.getName() + " does NOT support required profile");
|
CodecCapabilities caps = codecInfo.getCapabilitiesForType(mime);
|
||||||
}
|
|
||||||
else {
|
if (i == 0 && !decoderSupportsAndroidRLowLatency(codecInfo, mime)) {
|
||||||
return codecInfo;
|
LimeLog.info("Skipping decoder that lacks FEATURE_LowLatency for round 1");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredProfile != -1) {
|
||||||
|
for (CodecProfileLevel profile : caps.profileLevels) {
|
||||||
|
if (profile.profile == requiredProfile) {
|
||||||
|
LimeLog.info("Decoder " + codecInfo.getName() + " supports required profile");
|
||||||
|
return codecInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LimeLog.info("Decoder " + codecInfo.getName() + " does NOT support required profile");
|
||||||
|
} else {
|
||||||
|
return codecInfo;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -681,8 +961,7 @@ public class MediaCodecHelper {
|
|||||||
|
|
||||||
public static String readCpuinfo() throws Exception {
|
public static String readCpuinfo() throws Exception {
|
||||||
StringBuilder cpuInfo = new StringBuilder();
|
StringBuilder cpuInfo = new StringBuilder();
|
||||||
BufferedReader br = new BufferedReader(new FileReader(new File("/proc/cpuinfo")));
|
try (final BufferedReader br = new BufferedReader(new FileReader(new File("/proc/cpuinfo")))) {
|
||||||
try {
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
int ch = br.read();
|
int ch = br.read();
|
||||||
if (ch == -1)
|
if (ch == -1)
|
||||||
@@ -691,8 +970,6 @@ public class MediaCodecHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return cpuInfo.toString();
|
return cpuInfo.toString();
|
||||||
} finally {
|
|
||||||
br.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.limelight.binding.video;
|
package com.limelight.binding.video;
|
||||||
|
|
||||||
|
import android.os.SystemClock;
|
||||||
|
|
||||||
class VideoStats {
|
class VideoStats {
|
||||||
|
|
||||||
long decoderTimeMs;
|
long decoderTimeMs;
|
||||||
@@ -9,6 +11,10 @@ class VideoStats {
|
|||||||
int totalFramesRendered;
|
int totalFramesRendered;
|
||||||
int frameLossEvents;
|
int frameLossEvents;
|
||||||
int framesLost;
|
int framesLost;
|
||||||
|
char minHostProcessingLatency;
|
||||||
|
char maxHostProcessingLatency;
|
||||||
|
int totalHostProcessingLatency;
|
||||||
|
int framesWithHostProcessingLatency;
|
||||||
long measurementStartTimestamp;
|
long measurementStartTimestamp;
|
||||||
|
|
||||||
void add(VideoStats other) {
|
void add(VideoStats other) {
|
||||||
@@ -20,11 +26,20 @@ class VideoStats {
|
|||||||
this.frameLossEvents += other.frameLossEvents;
|
this.frameLossEvents += other.frameLossEvents;
|
||||||
this.framesLost += other.framesLost;
|
this.framesLost += other.framesLost;
|
||||||
|
|
||||||
|
if (this.minHostProcessingLatency == 0) {
|
||||||
|
this.minHostProcessingLatency = other.minHostProcessingLatency;
|
||||||
|
} else {
|
||||||
|
this.minHostProcessingLatency = (char) Math.min(this.minHostProcessingLatency, other.minHostProcessingLatency);
|
||||||
|
}
|
||||||
|
this.maxHostProcessingLatency = (char) Math.max(this.maxHostProcessingLatency, other.maxHostProcessingLatency);
|
||||||
|
this.totalHostProcessingLatency += other.totalHostProcessingLatency;
|
||||||
|
this.framesWithHostProcessingLatency += other.framesWithHostProcessingLatency;
|
||||||
|
|
||||||
if (this.measurementStartTimestamp == 0) {
|
if (this.measurementStartTimestamp == 0) {
|
||||||
this.measurementStartTimestamp = other.measurementStartTimestamp;
|
this.measurementStartTimestamp = other.measurementStartTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert other.measurementStartTimestamp <= this.measurementStartTimestamp;
|
assert other.measurementStartTimestamp >= this.measurementStartTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
void copy(VideoStats other) {
|
void copy(VideoStats other) {
|
||||||
@@ -35,6 +50,10 @@ class VideoStats {
|
|||||||
this.totalFramesRendered = other.totalFramesRendered;
|
this.totalFramesRendered = other.totalFramesRendered;
|
||||||
this.frameLossEvents = other.frameLossEvents;
|
this.frameLossEvents = other.frameLossEvents;
|
||||||
this.framesLost = other.framesLost;
|
this.framesLost = other.framesLost;
|
||||||
|
this.minHostProcessingLatency = other.minHostProcessingLatency;
|
||||||
|
this.maxHostProcessingLatency = other.maxHostProcessingLatency;
|
||||||
|
this.totalHostProcessingLatency = other.totalHostProcessingLatency;
|
||||||
|
this.framesWithHostProcessingLatency = other.framesWithHostProcessingLatency;
|
||||||
this.measurementStartTimestamp = other.measurementStartTimestamp;
|
this.measurementStartTimestamp = other.measurementStartTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +65,15 @@ class VideoStats {
|
|||||||
this.totalFramesRendered = 0;
|
this.totalFramesRendered = 0;
|
||||||
this.frameLossEvents = 0;
|
this.frameLossEvents = 0;
|
||||||
this.framesLost = 0;
|
this.framesLost = 0;
|
||||||
|
this.minHostProcessingLatency = 0;
|
||||||
|
this.maxHostProcessingLatency = 0;
|
||||||
|
this.totalHostProcessingLatency = 0;
|
||||||
|
this.framesWithHostProcessingLatency = 0;
|
||||||
this.measurementStartTimestamp = 0;
|
this.measurementStartTimestamp = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
VideoStatsFps getFps() {
|
VideoStatsFps getFps() {
|
||||||
float elapsed = (System.currentTimeMillis() - this.measurementStartTimestamp) / (float) 1000;
|
float elapsed = (SystemClock.uptimeMillis() - this.measurementStartTimestamp) / (float) 1000;
|
||||||
|
|
||||||
VideoStatsFps fps = new VideoStatsFps();
|
VideoStatsFps fps = new VideoStatsFps();
|
||||||
if (elapsed > 0) {
|
if (elapsed > 0) {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import java.util.LinkedList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import com.limelight.LimeLog;
|
||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
|
import com.limelight.nvstream.http.NvHTTP;
|
||||||
|
|
||||||
import android.content.ContentValues;
|
import android.content.ContentValues;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
@@ -17,17 +19,28 @@ import android.database.Cursor;
|
|||||||
import android.database.sqlite.SQLiteDatabase;
|
import android.database.sqlite.SQLiteDatabase;
|
||||||
import android.database.sqlite.SQLiteException;
|
import android.database.sqlite.SQLiteException;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
public class ComputerDatabaseManager {
|
public class ComputerDatabaseManager {
|
||||||
private static final String COMPUTER_DB_NAME = "computers3.db";
|
private static final String COMPUTER_DB_NAME = "computers4.db";
|
||||||
private static final String COMPUTER_TABLE_NAME = "Computers";
|
private static final String COMPUTER_TABLE_NAME = "Computers";
|
||||||
private static final String COMPUTER_UUID_COLUMN_NAME = "UUID";
|
private static final String COMPUTER_UUID_COLUMN_NAME = "UUID";
|
||||||
private static final String COMPUTER_NAME_COLUMN_NAME = "ComputerName";
|
private static final String COMPUTER_NAME_COLUMN_NAME = "ComputerName";
|
||||||
private static final String ADDRESSES_COLUMN_NAME = "Addresses";
|
private static final String ADDRESSES_COLUMN_NAME = "Addresses";
|
||||||
|
private interface AddressFields {
|
||||||
|
String LOCAL = "local";
|
||||||
|
String REMOTE = "remote";
|
||||||
|
String MANUAL = "manual";
|
||||||
|
String IPv6 = "ipv6";
|
||||||
|
|
||||||
|
String ADDRESS = "address";
|
||||||
|
String PORT = "port";
|
||||||
|
}
|
||||||
|
|
||||||
private static final String MAC_ADDRESS_COLUMN_NAME = "MacAddress";
|
private static final String MAC_ADDRESS_COLUMN_NAME = "MacAddress";
|
||||||
private static final String SERVER_CERT_COLUMN_NAME = "ServerCert";
|
private static final String SERVER_CERT_COLUMN_NAME = "ServerCert";
|
||||||
|
|
||||||
private static final char ADDRESS_DELIMITER = ';';
|
|
||||||
|
|
||||||
private SQLiteDatabase computerDb;
|
private SQLiteDatabase computerDb;
|
||||||
|
|
||||||
public ComputerDatabaseManager(Context c) {
|
public ComputerDatabaseManager(Context c) {
|
||||||
@@ -62,24 +75,54 @@ public class ComputerDatabaseManager {
|
|||||||
for (ComputerDetails computer : oldComputers) {
|
for (ComputerDetails computer : oldComputers) {
|
||||||
updateComputer(computer);
|
updateComputer(computer);
|
||||||
}
|
}
|
||||||
|
oldComputers = LegacyDatabaseReader3.migrateAllComputers(c);
|
||||||
|
for (ComputerDetails computer : oldComputers) {
|
||||||
|
updateComputer(computer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteComputer(ComputerDetails details) {
|
public void deleteComputer(ComputerDetails details) {
|
||||||
computerDb.delete(COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME+"=?", new String[]{details.uuid});
|
computerDb.delete(COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME+"=?", new String[]{details.uuid});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static JSONObject tupleToJson(ComputerDetails.AddressTuple tuple) throws JSONException {
|
||||||
|
if (tuple == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject json = new JSONObject();
|
||||||
|
json.put(AddressFields.ADDRESS, tuple.address);
|
||||||
|
json.put(AddressFields.PORT, tuple.port);
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ComputerDetails.AddressTuple tupleFromJson(JSONObject json, String name) throws JSONException {
|
||||||
|
if (!json.has(name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject address = json.getJSONObject(name);
|
||||||
|
return new ComputerDetails.AddressTuple(
|
||||||
|
address.getString(AddressFields.ADDRESS), address.getInt(AddressFields.PORT));
|
||||||
|
}
|
||||||
|
|
||||||
public boolean updateComputer(ComputerDetails details) {
|
public boolean updateComputer(ComputerDetails details) {
|
||||||
ContentValues values = new ContentValues();
|
ContentValues values = new ContentValues();
|
||||||
values.put(COMPUTER_UUID_COLUMN_NAME, details.uuid);
|
values.put(COMPUTER_UUID_COLUMN_NAME, details.uuid);
|
||||||
values.put(COMPUTER_NAME_COLUMN_NAME, details.name);
|
values.put(COMPUTER_NAME_COLUMN_NAME, details.name);
|
||||||
|
|
||||||
StringBuilder addresses = new StringBuilder();
|
try {
|
||||||
addresses.append(details.localAddress != null ? details.localAddress : "");
|
JSONObject addresses = new JSONObject();
|
||||||
addresses.append(ADDRESS_DELIMITER).append(details.remoteAddress != null ? details.remoteAddress : "");
|
addresses.put(AddressFields.LOCAL, tupleToJson(details.localAddress));
|
||||||
addresses.append(ADDRESS_DELIMITER).append(details.manualAddress != null ? details.manualAddress : "");
|
addresses.put(AddressFields.REMOTE, tupleToJson(details.remoteAddress));
|
||||||
addresses.append(ADDRESS_DELIMITER).append(details.ipv6Address != null ? details.ipv6Address : "");
|
addresses.put(AddressFields.MANUAL, tupleToJson(details.manualAddress));
|
||||||
|
addresses.put(AddressFields.IPv6, tupleToJson(details.ipv6Address));
|
||||||
|
values.put(ADDRESSES_COLUMN_NAME, addresses.toString());
|
||||||
|
} catch (JSONException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
values.put(ADDRESSES_COLUMN_NAME, addresses.toString());
|
|
||||||
values.put(MAC_ADDRESS_COLUMN_NAME, details.macAddress);
|
values.put(MAC_ADDRESS_COLUMN_NAME, details.macAddress);
|
||||||
try {
|
try {
|
||||||
if (details.serverCert != null) {
|
if (details.serverCert != null) {
|
||||||
@@ -95,26 +138,28 @@ public class ComputerDatabaseManager {
|
|||||||
return -1 != computerDb.insertWithOnConflict(COMPUTER_TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE);
|
return -1 != computerDb.insertWithOnConflict(COMPUTER_TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String readNonEmptyString(String input) {
|
|
||||||
if (input.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ComputerDetails getComputerFromCursor(Cursor c) {
|
private ComputerDetails getComputerFromCursor(Cursor c) {
|
||||||
ComputerDetails details = new ComputerDetails();
|
ComputerDetails details = new ComputerDetails();
|
||||||
|
|
||||||
details.uuid = c.getString(0);
|
details.uuid = c.getString(0);
|
||||||
details.name = c.getString(1);
|
details.name = c.getString(1);
|
||||||
|
try {
|
||||||
|
JSONObject addresses = new JSONObject(c.getString(2));
|
||||||
|
details.localAddress = tupleFromJson(addresses, AddressFields.LOCAL);
|
||||||
|
details.remoteAddress = tupleFromJson(addresses, AddressFields.REMOTE);
|
||||||
|
details.manualAddress = tupleFromJson(addresses, AddressFields.MANUAL);
|
||||||
|
details.ipv6Address = tupleFromJson(addresses, AddressFields.IPv6);
|
||||||
|
} catch (JSONException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
String[] addresses = c.getString(2).split(""+ADDRESS_DELIMITER, -1);
|
// External port is persisted in the remote address field
|
||||||
|
if (details.remoteAddress != null) {
|
||||||
details.localAddress = readNonEmptyString(addresses[0]);
|
details.externalPort = details.remoteAddress.port;
|
||||||
details.remoteAddress = readNonEmptyString(addresses[1]);
|
}
|
||||||
details.manualAddress = readNonEmptyString(addresses[2]);
|
else {
|
||||||
details.ipv6Address = readNonEmptyString(addresses[3]);
|
details.externalPort = NvHTTP.DEFAULT_HTTP_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
details.macAddress = c.getString(3);
|
details.macAddress = c.getString(3);
|
||||||
|
|
||||||
@@ -136,28 +181,55 @@ public class ComputerDatabaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<ComputerDetails> getAllComputers() {
|
public List<ComputerDetails> getAllComputers() {
|
||||||
Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null);
|
try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) {
|
||||||
LinkedList<ComputerDetails> computerList = new LinkedList<>();
|
LinkedList<ComputerDetails> computerList = new LinkedList<>();
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
computerList.add(getComputerFromCursor(c));
|
computerList.add(getComputerFromCursor(c));
|
||||||
|
}
|
||||||
|
return computerList;
|
||||||
}
|
}
|
||||||
|
|
||||||
c.close();
|
|
||||||
|
|
||||||
return computerList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ComputerDetails getComputerByUUID(String uuid) {
|
/**
|
||||||
Cursor c = computerDb.query(COMPUTER_TABLE_NAME, null, COMPUTER_UUID_COLUMN_NAME+"=?", new String[]{ uuid }, null, null, null);
|
* Get a computer by name
|
||||||
if (!c.moveToFirst()) {
|
* NOTE: It is perfectly valid for multiple computers to have the same name,
|
||||||
// No matching computer
|
* this function will only return the first one it finds.
|
||||||
c.close();
|
* Consider using getComputerByUUID instead.
|
||||||
return null;
|
* @param name The name of the computer
|
||||||
|
* @see ComputerDatabaseManager#getComputerByUUID(String) for alternative.
|
||||||
|
* @return The computer details, or null if no computer with that name exists
|
||||||
|
*/
|
||||||
|
public ComputerDetails getComputerByName(String name) {
|
||||||
|
try (final Cursor c = computerDb.query(
|
||||||
|
COMPUTER_TABLE_NAME, null, COMPUTER_NAME_COLUMN_NAME+"=?",
|
||||||
|
new String[]{ name }, null, null, null)
|
||||||
|
) {
|
||||||
|
if (!c.moveToFirst()) {
|
||||||
|
// No matching computer
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getComputerFromCursor(c);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ComputerDetails details = getComputerFromCursor(c);
|
/**
|
||||||
c.close();
|
* Get a computer by UUID
|
||||||
|
* @param uuid The UUID of the computer
|
||||||
|
* @see ComputerDatabaseManager#getComputerByName(String) for alternative.
|
||||||
|
* @return The computer details, or null if no computer with that UUID exists
|
||||||
|
*/
|
||||||
|
public ComputerDetails getComputerByUUID(String uuid) {
|
||||||
|
try (final Cursor c = computerDb.query(
|
||||||
|
COMPUTER_TABLE_NAME, null, COMPUTER_UUID_COLUMN_NAME+"=?",
|
||||||
|
new String[]{ uuid }, null, null, null)
|
||||||
|
) {
|
||||||
|
if (!c.moveToFirst()) {
|
||||||
|
// No matching computer
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return details;
|
return getComputerFromCursor(c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import java.io.IOException;
|
|||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.io.StringReader;
|
import java.io.StringReader;
|
||||||
import java.net.Inet4Address;
|
import java.net.Inet4Address;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.Socket;
|
import java.net.UnknownHostException;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -38,6 +38,7 @@ import android.net.NetworkCapabilities;
|
|||||||
import android.os.Binder;
|
import android.os.Binder;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
|
||||||
import org.xmlpull.v1.XmlPullParserException;
|
import org.xmlpull.v1.XmlPullParserException;
|
||||||
|
|
||||||
@@ -46,8 +47,7 @@ public class ComputerManagerService extends Service {
|
|||||||
private static final int APPLIST_POLLING_PERIOD_MS = 30000;
|
private static final int APPLIST_POLLING_PERIOD_MS = 30000;
|
||||||
private static final int APPLIST_FAILED_POLLING_RETRY_MS = 2000;
|
private static final int APPLIST_FAILED_POLLING_RETRY_MS = 2000;
|
||||||
private static final int MDNS_QUERY_PERIOD_MS = 1000;
|
private static final int MDNS_QUERY_PERIOD_MS = 1000;
|
||||||
private static final int FAST_POLL_TIMEOUT = 1000;
|
private static final int OFFLINE_POLL_TRIES = 3;
|
||||||
private static final int OFFLINE_POLL_TRIES = 5;
|
|
||||||
private static final int INITIAL_POLL_TRIES = 2;
|
private static final int INITIAL_POLL_TRIES = 2;
|
||||||
private static final int EMPTY_LIST_THRESHOLD = 3;
|
private static final int EMPTY_LIST_THRESHOLD = 3;
|
||||||
private static final int POLL_DATA_TTL_MS = 30000;
|
private static final int POLL_DATA_TTL_MS = 30000;
|
||||||
@@ -64,6 +64,8 @@ public class ComputerManagerService extends Service {
|
|||||||
private boolean pollingActive = false;
|
private boolean pollingActive = false;
|
||||||
private final Lock defaultNetworkLock = new ReentrantLock();
|
private final Lock defaultNetworkLock = new ReentrantLock();
|
||||||
|
|
||||||
|
private ConnectivityManager.NetworkCallback networkCallback;
|
||||||
|
|
||||||
private DiscoveryService.DiscoveryBinder discoveryBinder;
|
private DiscoveryService.DiscoveryBinder discoveryBinder;
|
||||||
private final ServiceConnection discoveryServiceConnection = new ServiceConnection() {
|
private final ServiceConnection discoveryServiceConnection = new ServiceConnection() {
|
||||||
public void onServiceConnected(ComponentName className, IBinder binder) {
|
public void onServiceConnected(ComponentName className, IBinder binder) {
|
||||||
@@ -134,6 +136,18 @@ public class ComputerManagerService extends Service {
|
|||||||
dbManager.updateComputer(existingComputer);
|
dbManager.updateComputer(existingComputer);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
try {
|
||||||
|
// If the active address is a site-local address (RFC 1918),
|
||||||
|
// then use STUN to populate the external address field if
|
||||||
|
// it's not set already.
|
||||||
|
if (details.remoteAddress == null) {
|
||||||
|
InetAddress addr = InetAddress.getByName(details.activeAddress.address);
|
||||||
|
if (addr.isSiteLocalAddress()) {
|
||||||
|
populateExternalAddress(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (UnknownHostException ignored) {}
|
||||||
|
|
||||||
dbManager.updateComputer(details);
|
dbManager.updateComputer(details);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +176,7 @@ public class ComputerManagerService extends Service {
|
|||||||
LimeLog.warning(tuple.computer.name + " is offline (try " + offlineCount + ")");
|
LimeLog.warning(tuple.computer.name + " is offline (try " + offlineCount + ")");
|
||||||
offlineCount++;
|
offlineCount++;
|
||||||
} else {
|
} else {
|
||||||
tuple.lastSuccessfulPollMs = System.currentTimeMillis();
|
tuple.lastSuccessfulPollMs = SystemClock.elapsedRealtime();
|
||||||
offlineCount = 0;
|
offlineCount = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +207,7 @@ public class ComputerManagerService extends Service {
|
|||||||
synchronized (pollingTuples) {
|
synchronized (pollingTuples) {
|
||||||
for (PollingTuple tuple : pollingTuples) {
|
for (PollingTuple tuple : pollingTuples) {
|
||||||
// Enforce the poll data TTL
|
// Enforce the poll data TTL
|
||||||
if (System.currentTimeMillis() - tuple.lastSuccessfulPollMs > POLL_DATA_TTL_MS) {
|
if (SystemClock.elapsedRealtime() - tuple.lastSuccessfulPollMs > POLL_DATA_TTL_MS) {
|
||||||
LimeLog.info("Timing out polled state for "+tuple.computer.name);
|
LimeLog.info("Timing out polled state for "+tuple.computer.name);
|
||||||
tuple.computer.state = ComputerDetails.State.UNKNOWN;
|
tuple.computer.state = ComputerDetails.State.UNKNOWN;
|
||||||
}
|
}
|
||||||
@@ -217,7 +231,13 @@ public class ComputerManagerService extends Service {
|
|||||||
// Wait for the bind notification
|
// Wait for the bind notification
|
||||||
discoveryServiceConnection.wait(1000);
|
discoveryServiceConnection.wait(1000);
|
||||||
}
|
}
|
||||||
} catch (InterruptedException ignored) {
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
|
||||||
|
// InterruptedException clears the thread's interrupt status. Since we can't
|
||||||
|
// handle that here, we will re-interrupt the thread to set the interrupt
|
||||||
|
// status back to true.
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,11 +246,18 @@ public class ComputerManagerService extends Service {
|
|||||||
while (activePolls.get() != 0) {
|
while (activePolls.get() != 0) {
|
||||||
try {
|
try {
|
||||||
Thread.sleep(250);
|
Thread.sleep(250);
|
||||||
} catch (InterruptedException ignored) {}
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
|
||||||
|
// InterruptedException clears the thread's interrupt status. Since we can't
|
||||||
|
// handle that here, we will re-interrupt the thread to set the interrupt
|
||||||
|
// status back to true.
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean addComputerBlocking(ComputerDetails fakeDetails) {
|
public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException {
|
||||||
return ComputerManagerService.this.addComputerBlocking(fakeDetails);
|
return ComputerManagerService.this.addComputerBlocking(fakeDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,52 +341,53 @@ public class ComputerManagerService extends Service {
|
|||||||
// Acquire the default network lock since we could be changing global process state
|
// Acquire the default network lock since we could be changing global process state
|
||||||
defaultNetworkLock.lock();
|
defaultNetworkLock.lock();
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
// On Lollipop or later, we can bind our process to the underlying interface
|
||||||
// On Lollipop or later, we can bind our process to the underlying interface
|
// to ensure our STUN request goes out on that interface or not at all (which is
|
||||||
// to ensure our STUN request goes out on that interface or not at all (which is
|
// preferable to getting a VPN endpoint address back).
|
||||||
// preferable to getting a VPN endpoint address back).
|
Network[] networks = connMgr.getAllNetworks();
|
||||||
Network[] networks = connMgr.getAllNetworks();
|
for (Network net : networks) {
|
||||||
for (Network net : networks) {
|
NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(net);
|
||||||
NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(net);
|
if (netCaps != null) {
|
||||||
if (netCaps != null) {
|
if (!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||||
if (!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||||
!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
// This network looks like an underlying multicast-capable transport,
|
||||||
// This network looks like an underlying multicast-capable transport,
|
// so let's guess that it's probably where our mDNS response came from.
|
||||||
// so let's guess that it's probably where our mDNS response came from.
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (connMgr.bindProcessToNetwork(net)) {
|
||||||
if (connMgr.bindProcessToNetwork(net)) {
|
|
||||||
boundToNetwork = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (ConnectivityManager.setProcessDefaultNetwork(net)) {
|
|
||||||
boundToNetwork = true;
|
boundToNetwork = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} else if (ConnectivityManager.setProcessDefaultNetwork(net)) {
|
||||||
|
boundToNetwork = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the STUN request if we're not on a VPN or if we bound to a network
|
// Perform the STUN request if we're not on a VPN or if we bound to a network
|
||||||
if (!activeNetworkIsVpn || boundToNetwork) {
|
if (!activeNetworkIsVpn || boundToNetwork) {
|
||||||
details.remoteAddress = NvConnection.findExternalAddressForMdns("stun.moonlight-stream.org", 3478);
|
String stunResolvedAddress = NvConnection.findExternalAddressForMdns("stun.moonlight-stream.org", 3478);
|
||||||
}
|
if (stunResolvedAddress != null) {
|
||||||
|
// We don't know for sure what the external port is, so we will have to guess.
|
||||||
// Unbind from the network
|
// When we contact the PC (if we haven't already), it will update the port.
|
||||||
if (boundToNetwork) {
|
details.remoteAddress = new ComputerDetails.AddressTuple(stunResolvedAddress, details.guessExternalPort());
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
}
|
||||||
connMgr.bindProcessToNetwork(null);
|
|
||||||
}
|
}
|
||||||
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
ConnectivityManager.setProcessDefaultNetwork(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock the network state
|
// Unbind from the network
|
||||||
if (activeNetworkIsVpn) {
|
if (boundToNetwork) {
|
||||||
defaultNetworkLock.unlock();
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
connMgr.bindProcessToNetwork(null);
|
||||||
|
} else {
|
||||||
|
ConnectivityManager.setProcessDefaultNetwork(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock the network state
|
||||||
|
if (activeNetworkIsVpn) {
|
||||||
|
defaultNetworkLock.unlock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +399,7 @@ public class ComputerManagerService extends Service {
|
|||||||
|
|
||||||
// Populate the computer template with mDNS info
|
// Populate the computer template with mDNS info
|
||||||
if (computer.getLocalAddress() != null) {
|
if (computer.getLocalAddress() != null) {
|
||||||
details.localAddress = computer.getLocalAddress().getHostAddress();
|
details.localAddress = new ComputerDetails.AddressTuple(computer.getLocalAddress().getHostAddress(), computer.getPort());
|
||||||
|
|
||||||
// Since we're on the same network, we can use STUN to find
|
// Since we're on the same network, we can use STUN to find
|
||||||
// our WAN address, which is also very likely the WAN address
|
// our WAN address, which is also very likely the WAN address
|
||||||
@@ -381,18 +409,22 @@ public class ComputerManagerService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (computer.getIpv6Address() != null) {
|
if (computer.getIpv6Address() != null) {
|
||||||
details.ipv6Address = computer.getIpv6Address().getHostAddress();
|
details.ipv6Address = new ComputerDetails.AddressTuple(computer.getIpv6Address().getHostAddress(), computer.getPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kick off a serverinfo poll on this machine
|
try {
|
||||||
if (!addComputerBlocking(details)) {
|
// Kick off a blocking serverinfo poll on this machine
|
||||||
LimeLog.warning("Auto-discovered PC failed to respond: "+details);
|
if (!addComputerBlocking(details)) {
|
||||||
}
|
LimeLog.warning("Auto-discovered PC failed to respond: "+details);
|
||||||
}
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
|
||||||
@Override
|
// InterruptedException clears the thread's interrupt status. Since we can't
|
||||||
public void notifyComputerRemoved(MdnsComputer computer) {
|
// handle that here, we will re-interrupt the thread to set the interrupt
|
||||||
// Nothing to do here
|
// status back to true.
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -434,28 +466,25 @@ public class ComputerManagerService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean addComputerBlocking(ComputerDetails fakeDetails) {
|
public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException {
|
||||||
// Block while we try to fill the details
|
// Block while we try to fill the details
|
||||||
try {
|
|
||||||
// We cannot use runPoll() here because it will attempt to persist the state of the machine
|
// We cannot use runPoll() here because it will attempt to persist the state of the machine
|
||||||
// in the database, which would be bad because we don't have our pinned cert loaded yet.
|
// in the database, which would be bad because we don't have our pinned cert loaded yet.
|
||||||
if (pollComputer(fakeDetails)) {
|
if (pollComputer(fakeDetails)) {
|
||||||
// See if we have record of this PC to pull its pinned cert
|
// See if we have record of this PC to pull its pinned cert
|
||||||
synchronized (pollingTuples) {
|
synchronized (pollingTuples) {
|
||||||
for (PollingTuple tuple : pollingTuples) {
|
for (PollingTuple tuple : pollingTuples) {
|
||||||
if (tuple.computer.uuid.equals(fakeDetails.uuid)) {
|
if (tuple.computer.uuid.equals(fakeDetails.uuid)) {
|
||||||
fakeDetails.serverCert = tuple.computer.serverCert;
|
fakeDetails.serverCert = tuple.computer.serverCert;
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll again, possibly with the pinned cert, to get accurate pairing information.
|
|
||||||
// This will insert the host into the database too.
|
|
||||||
runPoll(fakeDetails, true, 0);
|
|
||||||
}
|
}
|
||||||
} catch (InterruptedException e) {
|
|
||||||
return false;
|
// Poll again, possibly with the pinned cert, to get accurate pairing information.
|
||||||
|
// This will insert the host into the database too.
|
||||||
|
runPoll(fakeDetails, true, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the machine is reachable, it was successful
|
// If the machine is reachable, it was successful
|
||||||
@@ -512,17 +541,21 @@ public class ComputerManagerService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ComputerDetails tryPollIp(ComputerDetails details, String address) {
|
private ComputerDetails tryPollIp(ComputerDetails details, ComputerDetails.AddressTuple address) {
|
||||||
// Fast poll this address first to determine if we can connect at the TCP layer
|
|
||||||
if (!fastPollIp(address)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
NvHTTP http = new NvHTTP(address, idManager.getUniqueId(), details.serverCert,
|
// If the current address's port number matches the active address's port number, we can also assume
|
||||||
|
// the HTTPS port will also match. This assumption is currently safe because Sunshine sets all ports
|
||||||
|
// as offsets from the base HTTP port and doesn't allow custom HttpsPort responses for WAN vs LAN.
|
||||||
|
boolean portMatchesActiveAddress = details.state == ComputerDetails.State.ONLINE &&
|
||||||
|
details.activeAddress != null && address.port == details.activeAddress.port;
|
||||||
|
|
||||||
|
NvHTTP http = new NvHTTP(address, portMatchesActiveAddress ? details.httpsPort : 0, idManager.getUniqueId(), details.serverCert,
|
||||||
PlatformBinding.getCryptoProvider(ComputerManagerService.this));
|
PlatformBinding.getCryptoProvider(ComputerManagerService.this));
|
||||||
|
|
||||||
ComputerDetails newDetails = http.getComputerDetails();
|
// If this PC is currently online at this address, extend the timeouts to allow more time for the PC to respond.
|
||||||
|
boolean isLikelyOnline = details.state == ComputerDetails.State.ONLINE && address.equals(details.activeAddress);
|
||||||
|
|
||||||
|
ComputerDetails newDetails = http.getComputerDetails(isLikelyOnline);
|
||||||
|
|
||||||
// Check if this is the PC we expected
|
// Check if this is the PC we expected
|
||||||
if (newDetails.uuid == null) {
|
if (newDetails.uuid == null) {
|
||||||
@@ -536,146 +569,140 @@ public class ComputerManagerService extends Service {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the new active address
|
|
||||||
newDetails.activeAddress = address;
|
|
||||||
|
|
||||||
return newDetails;
|
return newDetails;
|
||||||
} catch (XmlPullParserException | IOException e) {
|
} catch (XmlPullParserException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return null;
|
return null;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Just try to establish a TCP connection to speculatively detect a running
|
private static class ParallelPollTuple {
|
||||||
// GFE server
|
public ComputerDetails.AddressTuple address;
|
||||||
private boolean fastPollIp(String address) {
|
public ComputerDetails existingDetails;
|
||||||
if (address == null) {
|
|
||||||
// Don't bother if our address is null
|
public boolean complete;
|
||||||
return false;
|
public Thread pollingThread;
|
||||||
|
public ComputerDetails returnedDetails;
|
||||||
|
|
||||||
|
public ParallelPollTuple(ComputerDetails.AddressTuple address, ComputerDetails existingDetails) {
|
||||||
|
this.address = address;
|
||||||
|
this.existingDetails = existingDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
Socket s = new Socket();
|
public void interrupt() {
|
||||||
try {
|
if (pollingThread != null) {
|
||||||
s.connect(new InetSocketAddress(address, NvHTTP.HTTPS_PORT), FAST_POLL_TIMEOUT);
|
pollingThread.interrupt();
|
||||||
s.close();
|
}
|
||||||
return true;
|
|
||||||
} catch (IOException e) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startFastPollThread(final String address, final boolean[] info) {
|
private void startParallelPollThread(ParallelPollTuple tuple, HashSet<ComputerDetails.AddressTuple> uniqueAddresses) {
|
||||||
Thread t = new Thread() {
|
// Don't bother starting a polling thread for an address that doesn't exist
|
||||||
|
// or if the address has already been polled with an earlier tuple
|
||||||
|
if (tuple.address == null || !uniqueAddresses.add(tuple.address)) {
|
||||||
|
tuple.complete = true;
|
||||||
|
tuple.returnedDetails = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tuple.pollingThread = new Thread() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
boolean pollRes = fastPollIp(address);
|
ComputerDetails details = tryPollIp(tuple.existingDetails, tuple.address);
|
||||||
|
|
||||||
synchronized (info) {
|
synchronized (tuple) {
|
||||||
info[0] = true; // Done
|
tuple.complete = true; // Done
|
||||||
info[1] = pollRes; // Polling result
|
tuple.returnedDetails = details; // Polling result
|
||||||
|
|
||||||
info.notify();
|
tuple.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
t.setName("Fast Poll - "+address);
|
tuple.pollingThread.setName("Parallel Poll - "+tuple.address+" - "+tuple.existingDetails.name);
|
||||||
t.start();
|
tuple.pollingThread.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String fastPollPc(final String localAddress, final String remoteAddress, final String manualAddress, final String ipv6Address) throws InterruptedException {
|
private ComputerDetails parallelPollPc(ComputerDetails details) throws InterruptedException {
|
||||||
final boolean[] remoteInfo = new boolean[2];
|
ParallelPollTuple localInfo = new ParallelPollTuple(details.localAddress, details);
|
||||||
final boolean[] localInfo = new boolean[2];
|
ParallelPollTuple manualInfo = new ParallelPollTuple(details.manualAddress, details);
|
||||||
final boolean[] manualInfo = new boolean[2];
|
ParallelPollTuple remoteInfo = new ParallelPollTuple(details.remoteAddress, details);
|
||||||
final boolean[] ipv6Info = new boolean[2];
|
ParallelPollTuple ipv6Info = new ParallelPollTuple(details.ipv6Address, details);
|
||||||
|
|
||||||
startFastPollThread(localAddress, localInfo);
|
// These must be started in order of precedence for the deduplication algorithm
|
||||||
startFastPollThread(remoteAddress, remoteInfo);
|
// to result in the correct behavior.
|
||||||
startFastPollThread(manualAddress, manualInfo);
|
HashSet<ComputerDetails.AddressTuple> uniqueAddresses = new HashSet<>();
|
||||||
startFastPollThread(ipv6Address, ipv6Info);
|
startParallelPollThread(localInfo, uniqueAddresses);
|
||||||
|
startParallelPollThread(manualInfo, uniqueAddresses);
|
||||||
|
startParallelPollThread(remoteInfo, uniqueAddresses);
|
||||||
|
startParallelPollThread(ipv6Info, uniqueAddresses);
|
||||||
|
|
||||||
// Check local first
|
try {
|
||||||
synchronized (localInfo) {
|
// Check local first
|
||||||
while (!localInfo[0]) {
|
synchronized (localInfo) {
|
||||||
localInfo.wait(500);
|
while (!localInfo.complete) {
|
||||||
|
localInfo.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localInfo.returnedDetails != null) {
|
||||||
|
localInfo.returnedDetails.activeAddress = localInfo.address;
|
||||||
|
return localInfo.returnedDetails;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localInfo[1]) {
|
// Now manual
|
||||||
return localAddress;
|
synchronized (manualInfo) {
|
||||||
}
|
while (!manualInfo.complete) {
|
||||||
}
|
manualInfo.wait();
|
||||||
|
}
|
||||||
|
|
||||||
// Now manual
|
if (manualInfo.returnedDetails != null) {
|
||||||
synchronized (manualInfo) {
|
manualInfo.returnedDetails.activeAddress = manualInfo.address;
|
||||||
while (!manualInfo[0]) {
|
return manualInfo.returnedDetails;
|
||||||
manualInfo.wait(500);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manualInfo[1]) {
|
// Now remote IPv4
|
||||||
return manualAddress;
|
synchronized (remoteInfo) {
|
||||||
}
|
while (!remoteInfo.complete) {
|
||||||
}
|
remoteInfo.wait();
|
||||||
|
}
|
||||||
|
|
||||||
// Now remote IPv4
|
if (remoteInfo.returnedDetails != null) {
|
||||||
synchronized (remoteInfo) {
|
remoteInfo.returnedDetails.activeAddress = remoteInfo.address;
|
||||||
while (!remoteInfo[0]) {
|
return remoteInfo.returnedDetails;
|
||||||
remoteInfo.wait(500);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remoteInfo[1]) {
|
// Now global IPv6
|
||||||
return remoteAddress;
|
synchronized (ipv6Info) {
|
||||||
}
|
while (!ipv6Info.complete) {
|
||||||
}
|
ipv6Info.wait();
|
||||||
|
}
|
||||||
|
|
||||||
// Now global IPv6
|
if (ipv6Info.returnedDetails != null) {
|
||||||
synchronized (ipv6Info) {
|
ipv6Info.returnedDetails.activeAddress = ipv6Info.address;
|
||||||
while (!ipv6Info[0]) {
|
return ipv6Info.returnedDetails;
|
||||||
ipv6Info.wait(500);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (ipv6Info[1]) {
|
|
||||||
return ipv6Address;
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
// Stop any further polling if we've found a working address or we've been
|
||||||
|
// interrupted by an attempt to stop polling.
|
||||||
|
localInfo.interrupt();
|
||||||
|
manualInfo.interrupt();
|
||||||
|
remoteInfo.interrupt();
|
||||||
|
ipv6Info.interrupt();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean pollComputer(ComputerDetails details) throws InterruptedException {
|
private boolean pollComputer(ComputerDetails details) throws InterruptedException {
|
||||||
ComputerDetails polledDetails;
|
// Poll all addresses in parallel to speed up the process
|
||||||
|
LimeLog.info("Starting parallel poll for "+details.name+" ("+details.localAddress +", "+details.remoteAddress +", "+details.manualAddress+", "+details.ipv6Address+")");
|
||||||
// Do a TCP-level connection to the HTTP server to see if it's listening.
|
ComputerDetails polledDetails = parallelPollPc(details);
|
||||||
// Do not write this address to details.activeAddress because:
|
LimeLog.info("Parallel poll for "+details.name+" returned address: "+details.activeAddress);
|
||||||
// a) it's only a candidate and may be wrong (multiple PCs behind a single router)
|
|
||||||
// b) if it's null, it will be unexpectedly nulling the activeAddress of a possibly online PC
|
|
||||||
LimeLog.info("Starting fast poll for "+details.name+" ("+details.localAddress +", "+details.remoteAddress +", "+details.manualAddress+", "+details.ipv6Address+")");
|
|
||||||
String candidateAddress = fastPollPc(details.localAddress, details.remoteAddress, details.manualAddress, details.ipv6Address);
|
|
||||||
LimeLog.info("Fast poll for "+details.name+" returned candidate address: "+candidateAddress);
|
|
||||||
|
|
||||||
// If no connection could be established to either IP address, there's nothing we can do
|
|
||||||
if (candidateAddress == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try using the active address from fast-poll
|
|
||||||
polledDetails = tryPollIp(details, candidateAddress);
|
|
||||||
if (polledDetails == null) {
|
|
||||||
// If that failed, try all unique addresses except what we've
|
|
||||||
// already tried
|
|
||||||
HashSet<String> uniqueAddresses = new HashSet<>();
|
|
||||||
uniqueAddresses.add(details.localAddress);
|
|
||||||
uniqueAddresses.add(details.manualAddress);
|
|
||||||
uniqueAddresses.add(details.remoteAddress);
|
|
||||||
uniqueAddresses.add(details.ipv6Address);
|
|
||||||
for (String addr : uniqueAddresses) {
|
|
||||||
if (addr == null || addr.equals(candidateAddress)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
polledDetails = tryPollIp(details, addr);
|
|
||||||
if (polledDetails != null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (polledDetails != null) {
|
if (polledDetails != null) {
|
||||||
details.update(polledDetails);
|
details.update(polledDetails);
|
||||||
@@ -710,10 +737,49 @@ public class ComputerManagerService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
releaseLocalDatabaseReference();
|
releaseLocalDatabaseReference();
|
||||||
|
|
||||||
|
// Monitor for network changes to invalidate our PC state
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
networkCallback = new ConnectivityManager.NetworkCallback() {
|
||||||
|
@Override
|
||||||
|
public void onAvailable(Network network) {
|
||||||
|
LimeLog.info("Resetting PC state for new available network");
|
||||||
|
synchronized (pollingTuples) {
|
||||||
|
for (PollingTuple tuple : pollingTuples) {
|
||||||
|
tuple.computer.state = ComputerDetails.State.UNKNOWN;
|
||||||
|
if (listener != null) {
|
||||||
|
listener.notifyComputerUpdated(tuple.computer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLost(Network network) {
|
||||||
|
LimeLog.info("Offlining PCs due to network loss");
|
||||||
|
synchronized (pollingTuples) {
|
||||||
|
for (PollingTuple tuple : pollingTuples) {
|
||||||
|
tuple.computer.state = ComputerDetails.State.OFFLINE;
|
||||||
|
if (listener != null) {
|
||||||
|
listener.notifyComputerUpdated(tuple.computer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
|
connMgr.registerDefaultNetworkCallback(networkCallback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
|
connMgr.unregisterNetworkCallback(networkCallback);
|
||||||
|
}
|
||||||
|
|
||||||
if (discoveryBinder != null) {
|
if (discoveryBinder != null) {
|
||||||
// Unbind from the discovery service
|
// Unbind from the discovery service
|
||||||
unbindService(discoveryServiceConnection);
|
unbindService(discoveryServiceConnection);
|
||||||
@@ -801,7 +867,7 @@ public class ComputerManagerService extends Service {
|
|||||||
PollingTuple tuple = getPollingTuple(computer);
|
PollingTuple tuple = getPollingTuple(computer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), idManager.getUniqueId(),
|
NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, idManager.getUniqueId(),
|
||||||
computer.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this));
|
computer.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this));
|
||||||
|
|
||||||
String appList;
|
String appList;
|
||||||
@@ -829,18 +895,12 @@ public class ComputerManagerService extends Service {
|
|||||||
if (!appList.isEmpty() &&
|
if (!appList.isEmpty() &&
|
||||||
(!list.isEmpty() || emptyAppListResponses >= EMPTY_LIST_THRESHOLD)) {
|
(!list.isEmpty() || emptyAppListResponses >= EMPTY_LIST_THRESHOLD)) {
|
||||||
// Open the cache file
|
// Open the cache file
|
||||||
OutputStream cacheOut = null;
|
try (final OutputStream cacheOut = CacheHelper.openCacheFileForOutput(
|
||||||
try {
|
getCacheDir(), "applist", computer.uuid)
|
||||||
cacheOut = CacheHelper.openCacheFileForOutput(getCacheDir(), "applist", computer.uuid);
|
) {
|
||||||
CacheHelper.writeStringToOutputStream(cacheOut, appList);
|
CacheHelper.writeStringToOutputStream(cacheOut, appList);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
if (cacheOut != null) {
|
|
||||||
cacheOut.close();
|
|
||||||
}
|
|
||||||
} catch (IOException ignored) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset empty count if it wasn't empty this time
|
// Reset empty count if it wasn't empty this time
|
||||||
|
|||||||
@@ -33,12 +33,11 @@ public class IdentityManager {
|
|||||||
private static String loadUniqueId(Context c) {
|
private static String loadUniqueId(Context c) {
|
||||||
// 2 Hex digits per byte
|
// 2 Hex digits per byte
|
||||||
char[] uid = new char[UID_SIZE_IN_BYTES * 2];
|
char[] uid = new char[UID_SIZE_IN_BYTES * 2];
|
||||||
InputStreamReader reader = null;
|
|
||||||
LimeLog.info("Reading UID from disk");
|
LimeLog.info("Reading UID from disk");
|
||||||
try {
|
try (final InputStreamReader reader =
|
||||||
reader = new InputStreamReader(c.openFileInput(UNIQUE_ID_FILE_NAME));
|
new InputStreamReader(c.openFileInput(UNIQUE_ID_FILE_NAME))
|
||||||
if (reader.read(uid) != UID_SIZE_IN_BYTES * 2)
|
) {
|
||||||
{
|
if (reader.read(uid) != UID_SIZE_IN_BYTES * 2) {
|
||||||
LimeLog.severe("UID file data is truncated");
|
LimeLog.severe("UID file data is truncated");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -50,12 +49,6 @@ public class IdentityManager {
|
|||||||
LimeLog.severe("Error while reading UID file");
|
LimeLog.severe("Error while reading UID file");
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
if (reader != null) {
|
|
||||||
try {
|
|
||||||
reader.close();
|
|
||||||
} catch (IOException ignored) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +57,14 @@ public class IdentityManager {
|
|||||||
LimeLog.info("Generating new UID");
|
LimeLog.info("Generating new UID");
|
||||||
String uidStr = String.format((Locale)null, "%016x", new Random().nextLong());
|
String uidStr = String.format((Locale)null, "%016x", new Random().nextLong());
|
||||||
|
|
||||||
OutputStreamWriter writer = null;
|
try (final OutputStreamWriter writer =
|
||||||
try {
|
new OutputStreamWriter(c.openFileOutput(UNIQUE_ID_FILE_NAME, 0))
|
||||||
writer = new OutputStreamWriter(c.openFileOutput(UNIQUE_ID_FILE_NAME, 0));
|
) {
|
||||||
writer.write(uidStr);
|
writer.write(uidStr);
|
||||||
LimeLog.info("UID written to disk");
|
LimeLog.info("UID written to disk");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LimeLog.severe("Error while writing UID file");
|
LimeLog.severe("Error while writing UID file");
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
} finally {
|
|
||||||
if (writer != null) {
|
|
||||||
try {
|
|
||||||
writer.close();
|
|
||||||
} catch (IOException ignored) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We can return a UID even if I/O fails
|
// We can return a UID even if I/O fails
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.database.sqlite.SQLiteException;
|
|||||||
|
|
||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
|
import com.limelight.nvstream.http.NvHTTP;
|
||||||
|
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
@@ -30,26 +31,26 @@ public class LegacyDatabaseReader {
|
|||||||
// too. To disambiguate, we'll need to prefix them with a string
|
// too. To disambiguate, we'll need to prefix them with a string
|
||||||
// greater than the allowable IP address length.
|
// greater than the allowable IP address length.
|
||||||
try {
|
try {
|
||||||
details.localAddress = InetAddress.getByAddress(c.getBlob(2)).getHostAddress();
|
details.localAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(2)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT);
|
||||||
LimeLog.warning("DB: Legacy local address for " + details.name);
|
LimeLog.warning("DB: Legacy local address for " + details.name);
|
||||||
} catch (UnknownHostException e) {
|
} catch (UnknownHostException e) {
|
||||||
// This is probably a hostname/address with the prefix string
|
// This is probably a hostname/address with the prefix string
|
||||||
String stringData = c.getString(2);
|
String stringData = c.getString(2);
|
||||||
if (stringData.startsWith(ADDRESS_PREFIX)) {
|
if (stringData.startsWith(ADDRESS_PREFIX)) {
|
||||||
details.localAddress = c.getString(2).substring(ADDRESS_PREFIX.length());
|
details.localAddress = new ComputerDetails.AddressTuple(c.getString(2).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT);
|
||||||
} else {
|
} else {
|
||||||
LimeLog.severe("DB: Corrupted local address for " + details.name);
|
LimeLog.severe("DB: Corrupted local address for " + details.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
details.remoteAddress = InetAddress.getByAddress(c.getBlob(3)).getHostAddress();
|
details.remoteAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(3)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT);
|
||||||
LimeLog.warning("DB: Legacy remote address for " + details.name);
|
LimeLog.warning("DB: Legacy remote address for " + details.name);
|
||||||
} catch (UnknownHostException e) {
|
} catch (UnknownHostException e) {
|
||||||
// This is probably a hostname/address with the prefix string
|
// This is probably a hostname/address with the prefix string
|
||||||
String stringData = c.getString(3);
|
String stringData = c.getString(3);
|
||||||
if (stringData.startsWith(ADDRESS_PREFIX)) {
|
if (stringData.startsWith(ADDRESS_PREFIX)) {
|
||||||
details.remoteAddress = c.getString(3).substring(ADDRESS_PREFIX.length());
|
details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT);
|
||||||
} else {
|
} else {
|
||||||
LimeLog.severe("DB: Corrupted remote address for " + details.name);
|
LimeLog.severe("DB: Corrupted remote address for " + details.name);
|
||||||
}
|
}
|
||||||
@@ -68,37 +69,34 @@ public class LegacyDatabaseReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static List<ComputerDetails> getAllComputers(SQLiteDatabase db) {
|
private static List<ComputerDetails> getAllComputers(SQLiteDatabase db) {
|
||||||
Cursor c = db.rawQuery("SELECT * FROM " + COMPUTER_TABLE_NAME, null);
|
try (final Cursor c = db.rawQuery("SELECT * FROM " + COMPUTER_TABLE_NAME, null)) {
|
||||||
LinkedList<ComputerDetails> computerList = new LinkedList<>();
|
LinkedList<ComputerDetails> computerList = new LinkedList<>();
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
ComputerDetails details = getComputerFromCursor(c);
|
ComputerDetails details = getComputerFromCursor(c);
|
||||||
|
|
||||||
// If a critical field is corrupt or missing, skip the database entry
|
// If a critical field is corrupt or missing, skip the database entry
|
||||||
if (details.uuid == null) {
|
if (details.uuid == null) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
computerList.add(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
computerList.add(details);
|
return computerList;
|
||||||
}
|
}
|
||||||
|
|
||||||
c.close();
|
|
||||||
|
|
||||||
return computerList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<ComputerDetails> migrateAllComputers(Context c) {
|
public static List<ComputerDetails> migrateAllComputers(Context c) {
|
||||||
SQLiteDatabase computerDb = null;
|
try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase(
|
||||||
try {
|
c.getDatabasePath(COMPUTER_DB_NAME).getPath(),
|
||||||
|
null, SQLiteDatabase.OPEN_READONLY)
|
||||||
|
) {
|
||||||
// Open the existing database
|
// Open the existing database
|
||||||
computerDb = SQLiteDatabase.openDatabase(c.getDatabasePath(COMPUTER_DB_NAME).getPath(), null, SQLiteDatabase.OPEN_READONLY);
|
|
||||||
return getAllComputers(computerDb);
|
return getAllComputers(computerDb);
|
||||||
} catch (SQLiteException e) {
|
} catch (SQLiteException e) {
|
||||||
return new LinkedList<ComputerDetails>();
|
return new LinkedList<ComputerDetails>();
|
||||||
} finally {
|
} finally {
|
||||||
// Close and delete the old DB
|
// Close and delete the old DB
|
||||||
if (computerDb != null) {
|
|
||||||
computerDb.close();
|
|
||||||
}
|
|
||||||
c.deleteDatabase(COMPUTER_DB_NAME);
|
c.deleteDatabase(COMPUTER_DB_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.database.sqlite.SQLiteDatabase;
|
|||||||
import android.database.sqlite.SQLiteException;
|
import android.database.sqlite.SQLiteException;
|
||||||
|
|
||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
|
import com.limelight.nvstream.http.NvHTTP;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.security.cert.CertificateException;
|
import java.security.cert.CertificateException;
|
||||||
@@ -23,9 +24,9 @@ public class LegacyDatabaseReader2 {
|
|||||||
|
|
||||||
details.uuid = c.getString(0);
|
details.uuid = c.getString(0);
|
||||||
details.name = c.getString(1);
|
details.name = c.getString(1);
|
||||||
details.localAddress = c.getString(2);
|
details.localAddress = new ComputerDetails.AddressTuple(c.getString(2), NvHTTP.DEFAULT_HTTP_PORT);
|
||||||
details.remoteAddress = c.getString(3);
|
details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3), NvHTTP.DEFAULT_HTTP_PORT);
|
||||||
details.manualAddress = c.getString(4);
|
details.manualAddress = new ComputerDetails.AddressTuple(c.getString(4), NvHTTP.DEFAULT_HTTP_PORT);
|
||||||
details.macAddress = c.getString(5);
|
details.macAddress = c.getString(5);
|
||||||
|
|
||||||
// This column wasn't always present in the old schema
|
// This column wasn't always present in the old schema
|
||||||
@@ -49,37 +50,34 @@ public class LegacyDatabaseReader2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static List<ComputerDetails> getAllComputers(SQLiteDatabase computerDb) {
|
public static List<ComputerDetails> getAllComputers(SQLiteDatabase computerDb) {
|
||||||
Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null);
|
try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) {
|
||||||
LinkedList<ComputerDetails> computerList = new LinkedList<>();
|
LinkedList<ComputerDetails> computerList = new LinkedList<>();
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
ComputerDetails details = getComputerFromCursor(c);
|
ComputerDetails details = getComputerFromCursor(c);
|
||||||
|
|
||||||
// If a critical field is corrupt or missing, skip the database entry
|
// If a critical field is corrupt or missing, skip the database entry
|
||||||
if (details.uuid == null) {
|
if (details.uuid == null) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
computerList.add(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
computerList.add(details);
|
return computerList;
|
||||||
}
|
}
|
||||||
|
|
||||||
c.close();
|
|
||||||
|
|
||||||
return computerList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<ComputerDetails> migrateAllComputers(Context c) {
|
public static List<ComputerDetails> migrateAllComputers(Context c) {
|
||||||
SQLiteDatabase computerDb = null;
|
try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase(
|
||||||
try {
|
c.getDatabasePath(COMPUTER_DB_NAME).getPath(),
|
||||||
|
null, SQLiteDatabase.OPEN_READONLY)
|
||||||
|
) {
|
||||||
// Open the existing database
|
// Open the existing database
|
||||||
computerDb = SQLiteDatabase.openDatabase(c.getDatabasePath(COMPUTER_DB_NAME).getPath(), null, SQLiteDatabase.OPEN_READONLY);
|
|
||||||
return getAllComputers(computerDb);
|
return getAllComputers(computerDb);
|
||||||
} catch (SQLiteException e) {
|
} catch (SQLiteException e) {
|
||||||
return new LinkedList<ComputerDetails>();
|
return new LinkedList<ComputerDetails>();
|
||||||
} finally {
|
} finally {
|
||||||
// Close and delete the old DB
|
// Close and delete the old DB
|
||||||
if (computerDb != null) {
|
|
||||||
computerDb.close();
|
|
||||||
}
|
|
||||||
c.deleteDatabase(COMPUTER_DB_NAME);
|
c.deleteDatabase(COMPUTER_DB_NAME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.limelight.computers;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.database.sqlite.SQLiteDatabase;
|
||||||
|
import android.database.sqlite.SQLiteException;
|
||||||
|
|
||||||
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
|
import com.limelight.nvstream.http.NvHTTP;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.security.cert.CertificateException;
|
||||||
|
import java.security.cert.CertificateFactory;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class LegacyDatabaseReader3 {
|
||||||
|
private static final String COMPUTER_DB_NAME = "computers3.db";
|
||||||
|
private static final String COMPUTER_TABLE_NAME = "Computers";
|
||||||
|
|
||||||
|
private static final char ADDRESS_DELIMITER = ';';
|
||||||
|
private static final char PORT_DELIMITER = '_';
|
||||||
|
|
||||||
|
private static String readNonEmptyString(String input) {
|
||||||
|
if (input.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ComputerDetails.AddressTuple splitAddressToTuple(String input) {
|
||||||
|
if (input == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] parts = input.split(""+PORT_DELIMITER, -1);
|
||||||
|
if (parts.length == 1) {
|
||||||
|
return new ComputerDetails.AddressTuple(parts[0], NvHTTP.DEFAULT_HTTP_PORT);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return new ComputerDetails.AddressTuple(parts[0], Integer.parseInt(parts[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String splitTupleToAddress(ComputerDetails.AddressTuple tuple) {
|
||||||
|
return tuple.address+PORT_DELIMITER+tuple.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ComputerDetails getComputerFromCursor(Cursor c) {
|
||||||
|
ComputerDetails details = new ComputerDetails();
|
||||||
|
|
||||||
|
details.uuid = c.getString(0);
|
||||||
|
details.name = c.getString(1);
|
||||||
|
|
||||||
|
String[] addresses = c.getString(2).split(""+ADDRESS_DELIMITER, -1);
|
||||||
|
|
||||||
|
details.localAddress = splitAddressToTuple(readNonEmptyString(addresses[0]));
|
||||||
|
details.remoteAddress = splitAddressToTuple(readNonEmptyString(addresses[1]));
|
||||||
|
details.manualAddress = splitAddressToTuple(readNonEmptyString(addresses[2]));
|
||||||
|
details.ipv6Address = splitAddressToTuple(readNonEmptyString(addresses[3]));
|
||||||
|
|
||||||
|
// External port is persisted in the remote address field
|
||||||
|
if (details.remoteAddress != null) {
|
||||||
|
details.externalPort = details.remoteAddress.port;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
details.externalPort = NvHTTP.DEFAULT_HTTP_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.macAddress = c.getString(3);
|
||||||
|
|
||||||
|
try {
|
||||||
|
byte[] derCertData = c.getBlob(4);
|
||||||
|
|
||||||
|
if (derCertData != null) {
|
||||||
|
details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(new ByteArrayInputStream(derCertData));
|
||||||
|
}
|
||||||
|
} catch (CertificateException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This signifies we don't have dynamic state (like pair state)
|
||||||
|
details.state = ComputerDetails.State.UNKNOWN;
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ComputerDetails> getAllComputers(SQLiteDatabase computerDb) {
|
||||||
|
try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) {
|
||||||
|
LinkedList<ComputerDetails> computerList = new LinkedList<>();
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
ComputerDetails details = getComputerFromCursor(c);
|
||||||
|
|
||||||
|
// If a critical field is corrupt or missing, skip the database entry
|
||||||
|
if (details.uuid == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
computerList.add(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
return computerList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ComputerDetails> migrateAllComputers(Context c) {
|
||||||
|
try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase(
|
||||||
|
c.getDatabasePath(COMPUTER_DB_NAME).getPath(),
|
||||||
|
null, SQLiteDatabase.OPEN_READONLY)
|
||||||
|
) {
|
||||||
|
// Open the existing database
|
||||||
|
return getAllComputers(computerDb);
|
||||||
|
} catch (SQLiteException e) {
|
||||||
|
return new LinkedList<ComputerDetails>();
|
||||||
|
} finally {
|
||||||
|
// Close and delete the old DB
|
||||||
|
c.deleteDatabase(COMPUTER_DB_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,21 @@ package com.limelight.discovery;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import com.limelight.nvstream.mdns.MdnsComputer;
|
import com.limelight.nvstream.mdns.MdnsComputer;
|
||||||
|
import com.limelight.nvstream.mdns.JmDNSDiscoveryAgent;
|
||||||
import com.limelight.nvstream.mdns.MdnsDiscoveryAgent;
|
import com.limelight.nvstream.mdns.MdnsDiscoveryAgent;
|
||||||
import com.limelight.nvstream.mdns.MdnsDiscoveryListener;
|
import com.limelight.nvstream.mdns.MdnsDiscoveryListener;
|
||||||
|
import com.limelight.nvstream.mdns.NsdManagerDiscoveryAgent;
|
||||||
|
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.net.wifi.WifiManager;
|
|
||||||
import android.net.wifi.WifiManager.MulticastLock;
|
|
||||||
import android.os.Binder;
|
import android.os.Binder;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
|
|
||||||
public class DiscoveryService extends Service {
|
public class DiscoveryService extends Service {
|
||||||
|
|
||||||
private MdnsDiscoveryAgent discoveryAgent;
|
private MdnsDiscoveryAgent discoveryAgent;
|
||||||
private MdnsDiscoveryListener boundListener;
|
private MdnsDiscoveryListener boundListener;
|
||||||
private MulticastLock multicastLock;
|
|
||||||
|
|
||||||
public class DiscoveryBinder extends Binder {
|
public class DiscoveryBinder extends Binder {
|
||||||
public void setListener(MdnsDiscoveryListener listener) {
|
public void setListener(MdnsDiscoveryListener listener) {
|
||||||
@@ -26,13 +25,11 @@ public class DiscoveryService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void startDiscovery(int queryIntervalMs) {
|
public void startDiscovery(int queryIntervalMs) {
|
||||||
multicastLock.acquire();
|
|
||||||
discoveryAgent.startDiscovery(queryIntervalMs);
|
discoveryAgent.startDiscovery(queryIntervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void stopDiscovery() {
|
public void stopDiscovery() {
|
||||||
discoveryAgent.stopDiscovery();
|
discoveryAgent.stopDiscovery();
|
||||||
multicastLock.release();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<MdnsComputer> getComputerSet() {
|
public List<MdnsComputer> getComputerSet() {
|
||||||
@@ -42,11 +39,7 @@ public class DiscoveryService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
WifiManager wifiMgr = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);
|
MdnsDiscoveryListener listener = new MdnsDiscoveryListener() {
|
||||||
multicastLock = wifiMgr.createMulticastLock("Limelight mDNS");
|
|
||||||
multicastLock.setReferenceCounted(false);
|
|
||||||
|
|
||||||
discoveryAgent = new MdnsDiscoveryAgent(new MdnsDiscoveryListener() {
|
|
||||||
@Override
|
@Override
|
||||||
public void notifyComputerAdded(MdnsComputer computer) {
|
public void notifyComputerAdded(MdnsComputer computer) {
|
||||||
if (boundListener != null) {
|
if (boundListener != null) {
|
||||||
@@ -54,20 +47,28 @@ public class DiscoveryService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void notifyComputerRemoved(MdnsComputer computer) {
|
|
||||||
if (boundListener != null) {
|
|
||||||
boundListener.notifyComputerRemoved(computer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void notifyDiscoveryFailure(Exception e) {
|
public void notifyDiscoveryFailure(Exception e) {
|
||||||
if (boundListener != null) {
|
if (boundListener != null) {
|
||||||
boundListener.notifyDiscoveryFailure(e);
|
boundListener.notifyDiscoveryFailure(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Prior to Android 14, NsdManager doesn't provide all the capabilities needed for parity
|
||||||
|
// with jmDNS (specifically handling multiple addresses for a single service). There are
|
||||||
|
// also documented reliability bugs early in the Android 4.x series shortly after it was
|
||||||
|
// introduced. The benefit of using NsdManager over jmDNS is that it works correctly in
|
||||||
|
// environments where mDNS proxying is required, like ChromeOS, WSA, and the emulator.
|
||||||
|
//
|
||||||
|
// As such, we use the jmDNS-based MdnsDiscoveryAgent prior to Android 14 and NsdManager
|
||||||
|
// on Android 14 and above.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
discoveryAgent = new JmDNSDiscoveryAgent(getApplicationContext(), listener);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
discoveryAgent = new NsdManagerDiscoveryAgent(getApplicationContext(), listener);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final DiscoveryBinder binder = new DiscoveryBinder();
|
private final DiscoveryBinder binder = new DiscoveryBinder();
|
||||||
@@ -81,7 +82,6 @@ public class DiscoveryService extends Service {
|
|||||||
public boolean onUnbind(Intent intent) {
|
public boolean onUnbind(Intent intent) {
|
||||||
// Stop any discovery session
|
// Stop any discovery session
|
||||||
discoveryAgent.stopDiscovery();
|
discoveryAgent.stopDiscovery();
|
||||||
multicastLock.release();
|
|
||||||
|
|
||||||
// Unbind the listener
|
// Unbind the listener
|
||||||
boundListener = null;
|
boundListener = null;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.limelight.grid;
|
|||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.graphics.BitmapFactory;
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.view.View;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.ProgressBar;
|
import android.widget.ProgressBar;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
@@ -16,8 +17,12 @@ import com.limelight.grid.assets.NetworkAssetLoader;
|
|||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
import com.limelight.preferences.PreferenceConfiguration;
|
import com.limelight.preferences.PreferenceConfiguration;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
||||||
@@ -27,23 +32,49 @@ public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
|||||||
|
|
||||||
private final ComputerDetails computer;
|
private final ComputerDetails computer;
|
||||||
private final String uniqueId;
|
private final String uniqueId;
|
||||||
|
private final boolean showHiddenApps;
|
||||||
|
|
||||||
private CachedAppAssetLoader loader;
|
private CachedAppAssetLoader loader;
|
||||||
|
private Set<Integer> hiddenAppIds = new HashSet<>();
|
||||||
|
private ArrayList<AppView.AppObject> allApps = new ArrayList<>();
|
||||||
|
|
||||||
public AppGridAdapter(Context context, PreferenceConfiguration prefs, ComputerDetails computer, String uniqueId) {
|
public AppGridAdapter(Context context, PreferenceConfiguration prefs, ComputerDetails computer, String uniqueId, boolean showHiddenApps) {
|
||||||
super(context, getLayoutIdForPreferences(prefs));
|
super(context, getLayoutIdForPreferences(prefs));
|
||||||
|
|
||||||
this.computer = computer;
|
this.computer = computer;
|
||||||
this.uniqueId = uniqueId;
|
this.uniqueId = uniqueId;
|
||||||
|
this.showHiddenApps = showHiddenApps;
|
||||||
|
|
||||||
updateLayoutWithPreferences(context, prefs);
|
updateLayoutWithPreferences(context, prefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) {
|
public void updateHiddenApps(Set<Integer> newHiddenAppIds, boolean hideImmediately) {
|
||||||
if (prefs.listMode) {
|
this.hiddenAppIds.clear();
|
||||||
return R.layout.simple_row;
|
this.hiddenAppIds.addAll(newHiddenAppIds);
|
||||||
|
|
||||||
|
if (hideImmediately) {
|
||||||
|
// Reconstruct the itemList with the new hidden app set
|
||||||
|
itemList.clear();
|
||||||
|
for (AppView.AppObject app : allApps) {
|
||||||
|
app.isHidden = hiddenAppIds.contains(app.app.getAppId());
|
||||||
|
|
||||||
|
if (!app.isHidden || showHiddenApps) {
|
||||||
|
itemList.add(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (prefs.smallIconMode) {
|
else {
|
||||||
|
// Just update the isHidden state to show the correct UI indication
|
||||||
|
for (AppView.AppObject app : allApps) {
|
||||||
|
app.isHidden = hiddenAppIds.contains(app.app.getAppId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) {
|
||||||
|
if (prefs.smallIconMode) {
|
||||||
return R.layout.app_grid_item_small;
|
return R.layout.app_grid_item_small;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -90,8 +121,8 @@ public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
|||||||
loader.freeCacheMemory();
|
loader.freeCacheMemory();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sortList() {
|
private static void sortList(List<AppView.AppObject> list) {
|
||||||
Collections.sort(itemList, new Comparator<AppView.AppObject>() {
|
Collections.sort(list, new Comparator<AppView.AppObject>() {
|
||||||
@Override
|
@Override
|
||||||
public int compare(AppView.AppObject lhs, AppView.AppObject rhs) {
|
public int compare(AppView.AppObject lhs, AppView.AppObject rhs) {
|
||||||
return lhs.app.getAppName().toLowerCase().compareTo(rhs.app.getAppName().toLowerCase());
|
return lhs.app.getAppName().toLowerCase().compareTo(rhs.app.getAppName().toLowerCase());
|
||||||
@@ -100,43 +131,54 @@ public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void addApp(AppView.AppObject app) {
|
public void addApp(AppView.AppObject app) {
|
||||||
// Queue a request to fetch this bitmap into cache
|
// Update hidden state
|
||||||
loader.queueCacheLoad(app.app);
|
app.isHidden = hiddenAppIds.contains(app.app.getAppId());
|
||||||
|
|
||||||
// Add the app to our sorted list
|
// Always add the app to the all apps list
|
||||||
itemList.add(app);
|
allApps.add(app);
|
||||||
sortList();
|
sortList(allApps);
|
||||||
|
|
||||||
|
// Add the app to the adapter data if it's not hidden
|
||||||
|
if (showHiddenApps || !app.isHidden) {
|
||||||
|
// Queue a request to fetch this bitmap into cache
|
||||||
|
loader.queueCacheLoad(app.app);
|
||||||
|
|
||||||
|
// Add the app to our sorted list
|
||||||
|
itemList.add(app);
|
||||||
|
sortList(itemList);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void removeApp(AppView.AppObject app) {
|
public void removeApp(AppView.AppObject app) {
|
||||||
itemList.remove(app);
|
itemList.remove(app);
|
||||||
|
allApps.remove(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean populateImageView(ImageView imgView, ProgressBar prgView, AppView.AppObject obj) {
|
public void clear() {
|
||||||
|
super.clear();
|
||||||
|
allApps.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, AppView.AppObject obj) {
|
||||||
// Let the cached asset loader handle it
|
// Let the cached asset loader handle it
|
||||||
loader.populateImageView(obj.app, imgView, prgView);
|
loader.populateImageView(obj.app, imgView, txtView);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean populateTextView(TextView txtView, AppView.AppObject obj) {
|
|
||||||
// Select the text view so it starts marquee mode
|
|
||||||
txtView.setSelected(true);
|
|
||||||
|
|
||||||
// Return false to use the app's toString method
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean populateOverlayView(ImageView overlayView, AppView.AppObject obj) {
|
|
||||||
if (obj.isRunning) {
|
if (obj.isRunning) {
|
||||||
// Show the play button overlay
|
// Show the play button overlay
|
||||||
overlayView.setImageResource(R.drawable.ic_play);
|
overlayView.setImageResource(R.drawable.ic_play);
|
||||||
return true;
|
overlayView.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
overlayView.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No overlay
|
if (obj.isHidden) {
|
||||||
return false;
|
parentView.setAlpha(0.40f);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
parentView.setAlpha(1.0f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import android.widget.ProgressBar;
|
|||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import com.limelight.R;
|
import com.limelight.R;
|
||||||
import com.limelight.preferences.PreferenceConfiguration;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
||||||
@@ -55,9 +54,7 @@ public abstract class GenericGridAdapter<T> extends BaseAdapter {
|
|||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract boolean populateImageView(ImageView imgView, ProgressBar prgView, T obj);
|
public abstract void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, T obj);
|
||||||
public abstract boolean populateTextView(TextView txtView, T obj);
|
|
||||||
public abstract boolean populateOverlayView(ImageView overlayView, T obj);
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public View getView(int i, View convertView, ViewGroup viewGroup) {
|
public View getView(int i, View convertView, ViewGroup viewGroup) {
|
||||||
@@ -70,22 +67,7 @@ public abstract class GenericGridAdapter<T> extends BaseAdapter {
|
|||||||
TextView txtView = convertView.findViewById(R.id.grid_text);
|
TextView txtView = convertView.findViewById(R.id.grid_text);
|
||||||
ProgressBar prgView = convertView.findViewById(R.id.grid_spinner);
|
ProgressBar prgView = convertView.findViewById(R.id.grid_spinner);
|
||||||
|
|
||||||
if (imgView != null) {
|
populateView(convertView, imgView, prgView, txtView, overlayView, itemList.get(i));
|
||||||
if (!populateImageView(imgView, prgView, itemList.get(i))) {
|
|
||||||
imgView.setImageBitmap(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!populateTextView(txtView, itemList.get(i))) {
|
|
||||||
txtView.setText(itemList.get(i).toString());
|
|
||||||
}
|
|
||||||
if (overlayView != null) {
|
|
||||||
if (!populateOverlayView(overlayView, itemList.get(i))) {
|
|
||||||
overlayView.setVisibility(View.INVISIBLE);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
overlayView.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return convertView;
|
return convertView;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,7 @@ public class PcGridAdapter extends GenericGridAdapter<PcView.ComputerObject> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) {
|
private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) {
|
||||||
if (prefs.listMode) {
|
return R.layout.pc_grid_item;
|
||||||
return R.layout.simple_row;
|
|
||||||
}
|
|
||||||
else if (prefs.smallIconMode) {
|
|
||||||
return R.layout.pc_grid_item_small;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return R.layout.pc_grid_item;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) {
|
public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) {
|
||||||
@@ -57,7 +49,8 @@ public class PcGridAdapter extends GenericGridAdapter<PcView.ComputerObject> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean populateImageView(ImageView imgView, ProgressBar prgView, PcView.ComputerObject obj) {
|
public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, PcView.ComputerObject obj) {
|
||||||
|
imgView.setImageResource(R.drawable.ic_computer);
|
||||||
if (obj.details.state == ComputerDetails.State.ONLINE) {
|
if (obj.details.state == ComputerDetails.State.ONLINE) {
|
||||||
imgView.setAlpha(1.0f);
|
imgView.setAlpha(1.0f);
|
||||||
}
|
}
|
||||||
@@ -72,12 +65,7 @@ public class PcGridAdapter extends GenericGridAdapter<PcView.ComputerObject> {
|
|||||||
prgView.setVisibility(View.INVISIBLE);
|
prgView.setVisibility(View.INVISIBLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
imgView.setImageResource(R.drawable.ic_computer);
|
txtView.setText(obj.details.name);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean populateTextView(TextView txtView, PcView.ComputerObject obj) {
|
|
||||||
if (obj.details.state == ComputerDetails.State.ONLINE) {
|
if (obj.details.state == ComputerDetails.State.ONLINE) {
|
||||||
txtView.setAlpha(1.0f);
|
txtView.setAlpha(1.0f);
|
||||||
}
|
}
|
||||||
@@ -85,16 +73,10 @@ public class PcGridAdapter extends GenericGridAdapter<PcView.ComputerObject> {
|
|||||||
txtView.setAlpha(0.4f);
|
txtView.setAlpha(0.4f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return false to use the computer's toString method
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean populateOverlayView(ImageView overlayView, PcView.ComputerObject obj) {
|
|
||||||
if (obj.details.state == ComputerDetails.State.OFFLINE) {
|
if (obj.details.state == ComputerDetails.State.OFFLINE) {
|
||||||
overlayView.setImageResource(R.drawable.ic_pc_offline);
|
overlayView.setImageResource(R.drawable.ic_pc_offline);
|
||||||
overlayView.setAlpha(0.4f);
|
overlayView.setAlpha(0.4f);
|
||||||
return true;
|
overlayView.setVisibility(View.VISIBLE);
|
||||||
}
|
}
|
||||||
// We must check if the status is exactly online and unpaired
|
// We must check if the status is exactly online and unpaired
|
||||||
// to avoid colliding with the loading spinner when status is unknown
|
// to avoid colliding with the loading spinner when status is unknown
|
||||||
@@ -102,8 +84,10 @@ public class PcGridAdapter extends GenericGridAdapter<PcView.ComputerObject> {
|
|||||||
obj.details.pairState == PairingManager.PairState.NOT_PAIRED) {
|
obj.details.pairState == PairingManager.PairState.NOT_PAIRED) {
|
||||||
overlayView.setImageResource(R.drawable.ic_lock);
|
overlayView.setImageResource(R.drawable.ic_lock);
|
||||||
overlayView.setAlpha(1.0f);
|
overlayView.setAlpha(1.0f);
|
||||||
return true;
|
overlayView.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
overlayView.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import android.graphics.drawable.BitmapDrawable;
|
|||||||
import android.graphics.drawable.Drawable;
|
import android.graphics.drawable.Drawable;
|
||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
import android.view.animation.Animation;
|
||||||
|
import android.view.animation.AnimationUtils;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.ProgressBar;
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import com.limelight.R;
|
||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
import com.limelight.nvstream.http.NvApp;
|
import com.limelight.nvstream.http.NvApp;
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@ public class CachedAppAssetLoader {
|
|||||||
memoryLoader.clearCache();
|
memoryLoader.clearCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Bitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) {
|
private ScaledBitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) {
|
||||||
// Try 3 times
|
// Try 3 times
|
||||||
for (int i = 0; i < 3; i++) {
|
for (int i = 0; i < 3; i++) {
|
||||||
// Check again whether we've been cancelled or the image view is gone
|
// Check again whether we've been cancelled or the image view is gone
|
||||||
@@ -110,7 +113,7 @@ public class CachedAppAssetLoader {
|
|||||||
// If there's a task associated with this load, we should return the bitmap
|
// If there's a task associated with this load, we should return the bitmap
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
// If the cached bitmap is valid, return it. Otherwise, we'll try the load again
|
// If the cached bitmap is valid, return it. Otherwise, we'll try the load again
|
||||||
Bitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
||||||
if (bmp != null) {
|
if (bmp != null) {
|
||||||
return bmp;
|
return bmp;
|
||||||
}
|
}
|
||||||
@@ -125,6 +128,13 @@ public class CachedAppAssetLoader {
|
|||||||
try {
|
try {
|
||||||
Thread.sleep((int) (1000 + (Math.random() * 500)));
|
Thread.sleep((int) (1000 + (Math.random() * 500)));
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
|
||||||
|
// InterruptedException clears the thread's interrupt status. Since we can't
|
||||||
|
// handle that here, we will re-interrupt the thread to set the interrupt
|
||||||
|
// status back to true.
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,29 +142,29 @@ public class CachedAppAssetLoader {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private class LoaderTask extends AsyncTask<LoaderTuple, Void, Bitmap> {
|
private class LoaderTask extends AsyncTask<LoaderTuple, Void, ScaledBitmap> {
|
||||||
private final WeakReference<ImageView> imageViewRef;
|
private final WeakReference<ImageView> imageViewRef;
|
||||||
private final WeakReference<ProgressBar> progressViewRef;
|
private final WeakReference<TextView> textViewRef;
|
||||||
private final boolean diskOnly;
|
private final boolean diskOnly;
|
||||||
|
|
||||||
private LoaderTuple tuple;
|
private LoaderTuple tuple;
|
||||||
|
|
||||||
public LoaderTask(ImageView imageView, ProgressBar prgView, boolean diskOnly) {
|
public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly) {
|
||||||
this.imageViewRef = new WeakReference<>(imageView);
|
this.imageViewRef = new WeakReference<>(imageView);
|
||||||
this.progressViewRef = new WeakReference<>(prgView);
|
this.textViewRef = new WeakReference<>(textView);
|
||||||
this.diskOnly = diskOnly;
|
this.diskOnly = diskOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Bitmap doInBackground(LoaderTuple... params) {
|
protected ScaledBitmap doInBackground(LoaderTuple... params) {
|
||||||
tuple = params[0];
|
tuple = params[0];
|
||||||
|
|
||||||
// Check whether it has been cancelled or the views are gone
|
// Check whether it has been cancelled or the views are gone
|
||||||
if (isCancelled() || imageViewRef.get() == null || progressViewRef.get() == null) {
|
if (isCancelled() || imageViewRef.get() == null || textViewRef.get() == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Bitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
||||||
if (bmp == null) {
|
if (bmp == null) {
|
||||||
if (!diskOnly) {
|
if (!diskOnly) {
|
||||||
// Try to load the asset from the network
|
// Try to load the asset from the network
|
||||||
@@ -183,45 +193,61 @@ public class CachedAppAssetLoader {
|
|||||||
|
|
||||||
// If the current loader task for this view isn't us, do nothing
|
// If the current loader task for this view isn't us, do nothing
|
||||||
final ImageView imageView = imageViewRef.get();
|
final ImageView imageView = imageViewRef.get();
|
||||||
final ProgressBar prgView = progressViewRef.get();
|
final TextView textView = textViewRef.get();
|
||||||
if (getLoaderTask(imageView) == this) {
|
if (getLoaderTask(imageView) == this) {
|
||||||
// Now display the progress bar since we have to hit the network
|
|
||||||
if (prgView != null) {
|
|
||||||
prgView.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set off another loader task on the network executor. This time our AsyncDrawable
|
// Set off another loader task on the network executor. This time our AsyncDrawable
|
||||||
// will use the app image placeholder bitmap, rather than an empty bitmap.
|
// will use the app image placeholder bitmap, rather than an empty bitmap.
|
||||||
LoaderTask task = new LoaderTask(imageView, prgView, false);
|
LoaderTask task = new LoaderTask(imageView, textView, false);
|
||||||
AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getResources(), noAppImageBitmap, task);
|
AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getResources(), noAppImageBitmap, task);
|
||||||
imageView.setVisibility(View.VISIBLE);
|
|
||||||
imageView.setImageDrawable(asyncDrawable);
|
imageView.setImageDrawable(asyncDrawable);
|
||||||
|
imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein));
|
||||||
|
imageView.setVisibility(View.VISIBLE);
|
||||||
|
textView.setVisibility(View.VISIBLE);
|
||||||
task.executeOnExecutor(networkExecutor, tuple);
|
task.executeOnExecutor(networkExecutor, tuple);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPostExecute(Bitmap bitmap) {
|
protected void onPostExecute(final ScaledBitmap bitmap) {
|
||||||
// Do nothing if cancelled
|
// Do nothing if cancelled
|
||||||
if (isCancelled()) {
|
if (isCancelled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final ImageView imageView = imageViewRef.get();
|
final ImageView imageView = imageViewRef.get();
|
||||||
final ProgressBar prgView = progressViewRef.get();
|
final TextView textView = textViewRef.get();
|
||||||
if (getLoaderTask(imageView) == this) {
|
if (getLoaderTask(imageView) == this) {
|
||||||
// Set the bitmap
|
// Fade in the box art
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
imageView.setImageBitmap(bitmap);
|
// Show the text if it's a placeholder
|
||||||
}
|
textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
// Hide the progress bar
|
if (imageView.getVisibility() == View.VISIBLE) {
|
||||||
if (prgView != null) {
|
// Fade out the placeholder first
|
||||||
prgView.setVisibility(View.INVISIBLE);
|
Animation fadeOutAnimation = AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadeout);
|
||||||
}
|
fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() {
|
||||||
|
@Override
|
||||||
|
public void onAnimationStart(Animation animation) {}
|
||||||
|
|
||||||
// Show the view
|
@Override
|
||||||
imageView.setVisibility(View.VISIBLE);
|
public void onAnimationEnd(Animation animation) {
|
||||||
|
// Fade in the new box art
|
||||||
|
imageView.setImageBitmap(bitmap.bitmap);
|
||||||
|
imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAnimationRepeat(Animation animation) {}
|
||||||
|
});
|
||||||
|
imageView.startAnimation(fadeOutAnimation);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// View is invisible already, so just fade in the new art
|
||||||
|
imageView.setImageBitmap(bitmap.bitmap);
|
||||||
|
imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein));
|
||||||
|
imageView.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +325,13 @@ public class CachedAppAssetLoader {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean populateImageView(NvApp app, ImageView imgView, ProgressBar prgView) {
|
private boolean isBitmapPlaceholder(ScaledBitmap bitmap) {
|
||||||
|
return (bitmap == null) ||
|
||||||
|
(bitmap.originalWidth == 130 && bitmap.originalHeight == 180) || // GFE 2.0
|
||||||
|
(bitmap.originalWidth == 628 && bitmap.originalHeight == 888); // GFE 3.0
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean populateImageView(NvApp app, ImageView imgView, TextView textView) {
|
||||||
LoaderTuple tuple = new LoaderTuple(computer, app);
|
LoaderTuple tuple = new LoaderTuple(computer, app);
|
||||||
|
|
||||||
// If there's already a task in progress for this view,
|
// If there's already a task in progress for this view,
|
||||||
@@ -309,22 +341,26 @@ public class CachedAppAssetLoader {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the progress bar always on initial load
|
// Always set the name text so we have it if needed later
|
||||||
prgView.setVisibility(View.INVISIBLE);
|
textView.setText(app.getAppName());
|
||||||
|
|
||||||
// First, try the memory cache in the current context
|
// First, try the memory cache in the current context
|
||||||
Bitmap bmp = memoryLoader.loadBitmapFromCache(tuple);
|
ScaledBitmap bmp = memoryLoader.loadBitmapFromCache(tuple);
|
||||||
if (bmp != null) {
|
if (bmp != null) {
|
||||||
// Show the bitmap immediately
|
// Show the bitmap immediately
|
||||||
imgView.setVisibility(View.VISIBLE);
|
imgView.setVisibility(View.VISIBLE);
|
||||||
imgView.setImageBitmap(bmp);
|
imgView.setImageBitmap(bmp.bitmap);
|
||||||
|
|
||||||
|
// Show the text if it's a placeholder bitmap
|
||||||
|
textView.setVisibility(isBitmapPlaceholder(bmp) ? View.VISIBLE : View.GONE);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's not in memory, create an async task to load it. This task will be attached
|
// If it's not in memory, create an async task to load it. This task will be attached
|
||||||
// via AsyncDrawable to this view.
|
// via AsyncDrawable to this view.
|
||||||
final LoaderTask task = new LoaderTask(imgView, prgView, true);
|
final LoaderTask task = new LoaderTask(imgView, textView, true);
|
||||||
final AsyncDrawable asyncDrawable = new AsyncDrawable(imgView.getResources(), placeholderBitmap, task);
|
final AsyncDrawable asyncDrawable = new AsyncDrawable(imgView.getResources(), placeholderBitmap, task);
|
||||||
|
textView.setVisibility(View.INVISIBLE);
|
||||||
imgView.setVisibility(View.INVISIBLE);
|
imgView.setVisibility(View.INVISIBLE);
|
||||||
imgView.setImageDrawable(asyncDrawable);
|
imgView.setImageDrawable(asyncDrawable);
|
||||||
|
|
||||||
@@ -333,7 +369,7 @@ public class CachedAppAssetLoader {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LoaderTuple {
|
public static class LoaderTuple {
|
||||||
public final ComputerDetails computer;
|
public final ComputerDetails computer;
|
||||||
public final NvApp app;
|
public final NvApp app;
|
||||||
|
|
||||||
|
|||||||
@@ -28,14 +28,8 @@ public class DiskAssetLoader {
|
|||||||
|
|
||||||
public DiskAssetLoader(Context context) {
|
public DiskAssetLoader(Context context) {
|
||||||
this.cacheDir = context.getCacheDir();
|
this.cacheDir = context.getCacheDir();
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
this.isLowRamDevice =
|
||||||
this.isLowRamDevice =
|
((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE)).isLowRamDevice();
|
||||||
((ActivityManager)context.getSystemService(Context.ACTIVITY_SERVICE)).isLowRamDevice();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Use conservative low RAM behavior on very old devices
|
|
||||||
this.isLowRamDevice = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean checkCacheExists(CachedAppAssetLoader.LoaderTuple tuple) {
|
public boolean checkCacheExists(CachedAppAssetLoader.LoaderTuple tuple) {
|
||||||
@@ -64,7 +58,7 @@ public class DiskAssetLoader {
|
|||||||
return inSampleSize;
|
return inSampleSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Bitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, int sampleSize) {
|
public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, int sampleSize) {
|
||||||
File file = getFile(tuple.computer.uuid, tuple.app.getAppId());
|
File file = getFile(tuple.computer.uuid, tuple.app.getAppId());
|
||||||
|
|
||||||
// Don't bother with anything if it doesn't exist
|
// Don't bother with anything if it doesn't exist
|
||||||
@@ -110,27 +104,33 @@ public class DiskAssetLoader {
|
|||||||
bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
|
bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
|
||||||
if (bmp != null) {
|
if (bmp != null) {
|
||||||
LimeLog.info("Tuple "+tuple+" decoded from disk cache with sample size: "+options.inSampleSize);
|
LimeLog.info("Tuple "+tuple+" decoded from disk cache with sample size: "+options.inSampleSize);
|
||||||
|
return new ScaledBitmap(decodeOnlyOptions.outWidth, decodeOnlyOptions.outHeight, bmp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// On P, we can get a bitmap back in one step with ImageDecoder
|
// On P, we can get a bitmap back in one step with ImageDecoder
|
||||||
|
final ScaledBitmap scaledBitmap = new ScaledBitmap();
|
||||||
try {
|
try {
|
||||||
bmp = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), new ImageDecoder.OnHeaderDecodedListener() {
|
scaledBitmap.bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), new ImageDecoder.OnHeaderDecodedListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onHeaderDecoded(ImageDecoder imageDecoder, ImageDecoder.ImageInfo imageInfo, ImageDecoder.Source source) {
|
public void onHeaderDecoded(ImageDecoder imageDecoder, ImageDecoder.ImageInfo imageInfo, ImageDecoder.Source source) {
|
||||||
|
scaledBitmap.originalWidth = imageInfo.getSize().getWidth();
|
||||||
|
scaledBitmap.originalHeight = imageInfo.getSize().getHeight();
|
||||||
|
|
||||||
imageDecoder.setTargetSize(STANDARD_ASSET_WIDTH, STANDARD_ASSET_HEIGHT);
|
imageDecoder.setTargetSize(STANDARD_ASSET_WIDTH, STANDARD_ASSET_HEIGHT);
|
||||||
if (isLowRamDevice) {
|
if (isLowRamDevice) {
|
||||||
imageDecoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM);
|
imageDecoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return scaledBitmap;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bmp;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public File getFile(String computerUuid, int appId) {
|
public File getFile(String computerUuid, int appId) {
|
||||||
@@ -148,21 +148,15 @@ public class DiskAssetLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void populateCacheWithStream(CachedAppAssetLoader.LoaderTuple tuple, InputStream input) {
|
public void populateCacheWithStream(CachedAppAssetLoader.LoaderTuple tuple, InputStream input) {
|
||||||
OutputStream out = null;
|
|
||||||
boolean success = false;
|
boolean success = false;
|
||||||
try {
|
try (final OutputStream out = CacheHelper.openCacheFileForOutput(
|
||||||
out = CacheHelper.openCacheFileForOutput(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
|
cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png")
|
||||||
|
) {
|
||||||
CacheHelper.writeInputStreamToOutputStream(input, out, MAX_ASSET_SIZE);
|
CacheHelper.writeInputStreamToOutputStream(input, out, MAX_ASSET_SIZE);
|
||||||
success = true;
|
success = true;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
} finally {
|
} finally {
|
||||||
if (out != null) {
|
|
||||||
try {
|
|
||||||
out.close();
|
|
||||||
} catch (IOException ignored) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
LimeLog.warning("Unable to populate cache with tuple: "+tuple);
|
LimeLog.warning("Unable to populate cache with tuple: "+tuple);
|
||||||
CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
|
CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
|
||||||
|
|||||||
@@ -1,37 +1,74 @@
|
|||||||
package com.limelight.grid.assets;
|
package com.limelight.grid.assets;
|
||||||
|
|
||||||
import android.graphics.Bitmap;
|
|
||||||
import android.util.LruCache;
|
import android.util.LruCache;
|
||||||
|
|
||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
|
|
||||||
|
import java.lang.ref.SoftReference;
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
public class MemoryAssetLoader {
|
public class MemoryAssetLoader {
|
||||||
private static final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
|
private static final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
|
||||||
private static final LruCache<String, Bitmap> memoryCache = new LruCache<String, Bitmap>(maxMemory / 16) {
|
private static final LruCache<String, ScaledBitmap> memoryCache = new LruCache<String, ScaledBitmap>(maxMemory / 16) {
|
||||||
@Override
|
@Override
|
||||||
protected int sizeOf(String key, Bitmap bitmap) {
|
protected int sizeOf(String key, ScaledBitmap bitmap) {
|
||||||
// Sizeof returns kilobytes
|
// Sizeof returns kilobytes
|
||||||
return bitmap.getByteCount() / 1024;
|
return bitmap.bitmap.getByteCount() / 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void entryRemoved(boolean evicted, String key, ScaledBitmap oldValue, ScaledBitmap newValue) {
|
||||||
|
super.entryRemoved(evicted, key, oldValue, newValue);
|
||||||
|
|
||||||
|
if (evicted) {
|
||||||
|
// Keep a soft reference around to the bitmap as long as we can
|
||||||
|
evictionCache.put(key, new SoftReference<>(oldValue));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
private static final HashMap<String, SoftReference<ScaledBitmap>> evictionCache = new HashMap<>();
|
||||||
|
|
||||||
private static String constructKey(CachedAppAssetLoader.LoaderTuple tuple) {
|
private static String constructKey(CachedAppAssetLoader.LoaderTuple tuple) {
|
||||||
return tuple.computer.uuid+"-"+tuple.app.getAppId();
|
return tuple.computer.uuid+"-"+tuple.app.getAppId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Bitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple) {
|
public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple) {
|
||||||
Bitmap bmp = memoryCache.get(constructKey(tuple));
|
final String key = constructKey(tuple);
|
||||||
|
|
||||||
|
ScaledBitmap bmp = memoryCache.get(key);
|
||||||
if (bmp != null) {
|
if (bmp != null) {
|
||||||
LimeLog.info("Memory cache hit for tuple: "+tuple);
|
LimeLog.info("LRU cache hit for tuple: "+tuple);
|
||||||
|
return bmp;
|
||||||
}
|
}
|
||||||
return bmp;
|
|
||||||
|
SoftReference<ScaledBitmap> bmpRef = evictionCache.get(key);
|
||||||
|
if (bmpRef != null) {
|
||||||
|
bmp = bmpRef.get();
|
||||||
|
if (bmp != null) {
|
||||||
|
LimeLog.info("Eviction cache hit for tuple: "+tuple);
|
||||||
|
|
||||||
|
// Put this entry back into the LRU cache
|
||||||
|
evictionCache.remove(key);
|
||||||
|
memoryCache.put(key, bmp);
|
||||||
|
|
||||||
|
return bmp;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// The data is gone, so remove the dangling SoftReference now
|
||||||
|
evictionCache.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void populateCache(CachedAppAssetLoader.LoaderTuple tuple, Bitmap bitmap) {
|
public void populateCache(CachedAppAssetLoader.LoaderTuple tuple, ScaledBitmap bitmap) {
|
||||||
memoryCache.put(constructKey(tuple), bitmap);
|
memoryCache.put(constructKey(tuple), bitmap);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void clearCache() {
|
public void clearCache() {
|
||||||
|
// We must evict first because that will push all items into the eviction cache
|
||||||
memoryCache.evictAll();
|
memoryCache.evictAll();
|
||||||
|
evictionCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ public class NetworkAssetLoader {
|
|||||||
public InputStream getBitmapStream(CachedAppAssetLoader.LoaderTuple tuple) {
|
public InputStream getBitmapStream(CachedAppAssetLoader.LoaderTuple tuple) {
|
||||||
InputStream in = null;
|
InputStream in = null;
|
||||||
try {
|
try {
|
||||||
NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(tuple.computer), uniqueId,
|
NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(tuple.computer),
|
||||||
tuple.computer.serverCert, PlatformBinding.getCryptoProvider(context));
|
tuple.computer.httpsPort, uniqueId, tuple.computer.serverCert,
|
||||||
|
PlatformBinding.getCryptoProvider(context));
|
||||||
in = http.getBoxArt(tuple.app);
|
in = http.getBoxArt(tuple.app);
|
||||||
} catch (IOException ignored) {}
|
} catch (IOException ignored) {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.limelight.grid.assets;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
|
public class ScaledBitmap {
|
||||||
|
public int originalWidth;
|
||||||
|
public int originalHeight;
|
||||||
|
|
||||||
|
public Bitmap bitmap;
|
||||||
|
|
||||||
|
public ScaledBitmap() {}
|
||||||
|
|
||||||
|
public ScaledBitmap(int originalWidth, int originalHeight, Bitmap bitmap) {
|
||||||
|
this.originalWidth = originalWidth;
|
||||||
|
this.originalHeight = originalHeight;
|
||||||
|
this.bitmap = bitmap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.limelight.nvstream;
|
package com.limelight.nvstream;
|
||||||
|
|
||||||
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
|
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
|
|
||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
|
|
||||||
public class ConnectionContext {
|
public class ConnectionContext {
|
||||||
public String serverAddress;
|
public ComputerDetails.AddressTuple serverAddress;
|
||||||
|
public int httpsPort;
|
||||||
|
public boolean isNvidiaServerSoftware;
|
||||||
public X509Certificate serverCert;
|
public X509Certificate serverCert;
|
||||||
public StreamConfiguration streamConfig;
|
public StreamConfiguration streamConfig;
|
||||||
public NvConnectionListener connListener;
|
public NvConnectionListener connListener;
|
||||||
@@ -15,9 +19,16 @@ public class ConnectionContext {
|
|||||||
// This is the version quad from the appversion tag of /serverinfo
|
// This is the version quad from the appversion tag of /serverinfo
|
||||||
public String serverAppVersion;
|
public String serverAppVersion;
|
||||||
public String serverGfeVersion;
|
public String serverGfeVersion;
|
||||||
|
public int serverCodecModeSupport;
|
||||||
|
|
||||||
|
// This is the sessionUrl0 tag from /resume and /launch
|
||||||
|
public String rtspSessionUrl;
|
||||||
|
|
||||||
public int negotiatedWidth, negotiatedHeight;
|
public int negotiatedWidth, negotiatedHeight;
|
||||||
public boolean negotiatedHdr;
|
public boolean negotiatedHdr;
|
||||||
|
|
||||||
|
public int negotiatedRemoteStreaming;
|
||||||
|
public int negotiatedPacketSize;
|
||||||
|
|
||||||
public int videoCapabilities;
|
public int videoCapabilities;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
package com.limelight.nvstream;
|
package com.limelight.nvstream;
|
||||||
|
|
||||||
import android.app.ActivityManager;
|
import android.app.ActivityManager;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.net.ConnectivityManager;
|
||||||
|
import android.net.IpPrefix;
|
||||||
|
import android.net.LinkProperties;
|
||||||
|
import android.net.Network;
|
||||||
|
import android.net.NetworkCapabilities;
|
||||||
|
import android.net.NetworkInfo;
|
||||||
|
import android.net.RouteInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Timer;
|
||||||
|
import java.util.TimerTask;
|
||||||
import java.util.concurrent.Semaphore;
|
import java.util.concurrent.Semaphore;
|
||||||
|
|
||||||
import javax.crypto.KeyGenerator;
|
import javax.crypto.KeyGenerator;
|
||||||
@@ -17,7 +31,8 @@ import org.xmlpull.v1.XmlPullParserException;
|
|||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
import com.limelight.nvstream.av.audio.AudioRenderer;
|
import com.limelight.nvstream.av.audio.AudioRenderer;
|
||||||
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
|
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
|
||||||
import com.limelight.nvstream.http.GfeHttpResponseException;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
|
import com.limelight.nvstream.http.HostHttpResponseException;
|
||||||
import com.limelight.nvstream.http.LimelightCryptoProvider;
|
import com.limelight.nvstream.http.LimelightCryptoProvider;
|
||||||
import com.limelight.nvstream.http.NvApp;
|
import com.limelight.nvstream.http.NvApp;
|
||||||
import com.limelight.nvstream.http.NvHTTP;
|
import com.limelight.nvstream.http.NvHTTP;
|
||||||
@@ -27,41 +42,44 @@ import com.limelight.nvstream.jni.MoonBridge;
|
|||||||
|
|
||||||
public class NvConnection {
|
public class NvConnection {
|
||||||
// Context parameters
|
// Context parameters
|
||||||
private String host;
|
|
||||||
private LimelightCryptoProvider cryptoProvider;
|
private LimelightCryptoProvider cryptoProvider;
|
||||||
private String uniqueId;
|
private String uniqueId;
|
||||||
private ConnectionContext context;
|
private ConnectionContext context;
|
||||||
private static Semaphore connectionAllowed = new Semaphore(1);
|
private static Semaphore connectionAllowed = new Semaphore(1);
|
||||||
private final boolean isMonkey;
|
private final boolean isMonkey;
|
||||||
|
private final Context appContext;
|
||||||
public NvConnection(String host, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert)
|
|
||||||
{
|
public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert)
|
||||||
this.host = host;
|
{
|
||||||
|
this.appContext = appContext;
|
||||||
this.cryptoProvider = cryptoProvider;
|
this.cryptoProvider = cryptoProvider;
|
||||||
this.uniqueId = uniqueId;
|
this.uniqueId = uniqueId;
|
||||||
|
|
||||||
this.context = new ConnectionContext();
|
this.context = new ConnectionContext();
|
||||||
|
this.context.serverAddress = host;
|
||||||
|
this.context.httpsPort = httpsPort;
|
||||||
this.context.streamConfig = config;
|
this.context.streamConfig = config;
|
||||||
this.context.serverCert = serverCert;
|
this.context.serverCert = serverCert;
|
||||||
try {
|
|
||||||
// This is unique per connection
|
// This is unique per connection
|
||||||
this.context.riKey = generateRiAesKey();
|
this.context.riKey = generateRiAesKey();
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
// Should never happen
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.context.riKeyId = generateRiKeyId();
|
this.context.riKeyId = generateRiKeyId();
|
||||||
|
|
||||||
this.isMonkey = ActivityManager.isUserAMonkey();
|
this.isMonkey = ActivityManager.isUserAMonkey();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SecretKey generateRiAesKey() throws NoSuchAlgorithmException {
|
private static SecretKey generateRiAesKey() {
|
||||||
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
|
try {
|
||||||
|
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
|
||||||
// RI keys are 128 bits
|
|
||||||
keyGen.init(128);
|
// RI keys are 128 bits
|
||||||
|
keyGen.init(128);
|
||||||
return keyGen.generateKey();
|
|
||||||
|
return keyGen.generateKey();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int generateRiKeyId() {
|
private static int generateRiKeyId() {
|
||||||
@@ -82,12 +100,130 @@ public class NvConnection {
|
|||||||
// Now a pending connection can be processed
|
// Now a pending connection can be processed
|
||||||
connectionAllowed.release();
|
connectionAllowed.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private InetAddress resolveServerAddress() throws IOException {
|
||||||
|
// Try to find an address that works for this host
|
||||||
|
InetAddress[] addrs = InetAddress.getAllByName(context.serverAddress.address);
|
||||||
|
for (InetAddress addr : addrs) {
|
||||||
|
try (Socket s = new Socket()) {
|
||||||
|
s.setSoLinger(true, 0);
|
||||||
|
s.connect(new InetSocketAddress(addr, context.serverAddress.port), 1000);
|
||||||
|
return addr;
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we made it here, we didn't manage to find a working address. If DNS returned any
|
||||||
|
// address, we'll use the first available address and hope for the best.
|
||||||
|
if (addrs.length > 0) {
|
||||||
|
return addrs[0];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new IOException("No addresses found for "+context.serverAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int detectServerConnectionType() {
|
||||||
|
ConnectivityManager connMgr = (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
Network activeNetwork = connMgr.getActiveNetwork();
|
||||||
|
if (activeNetwork != null) {
|
||||||
|
NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(activeNetwork);
|
||||||
|
if (netCaps != null) {
|
||||||
|
if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ||
|
||||||
|
!netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
|
||||||
|
// VPNs are treated as remote connections
|
||||||
|
return StreamConfiguration.STREAM_CFG_REMOTE;
|
||||||
|
}
|
||||||
|
else if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||||
|
// Cellular is always treated as remote to avoid any possible
|
||||||
|
// issues with 464XLAT or similar technologies.
|
||||||
|
return StreamConfiguration.STREAM_CFG_REMOTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the server address is on-link
|
||||||
|
LinkProperties linkProperties = connMgr.getLinkProperties(activeNetwork);
|
||||||
|
if (linkProperties != null) {
|
||||||
|
InetAddress serverAddress;
|
||||||
|
try {
|
||||||
|
serverAddress = resolveServerAddress();
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
|
||||||
|
// We can't decide without being able to resolve the server address
|
||||||
|
return StreamConfiguration.STREAM_CFG_AUTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the address is in the NAT64 prefix, always treat it as remote
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
IpPrefix nat64Prefix = linkProperties.getNat64Prefix();
|
||||||
|
if (nat64Prefix != null && nat64Prefix.contains(serverAddress)) {
|
||||||
|
return StreamConfiguration.STREAM_CFG_REMOTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (RouteInfo route : linkProperties.getRoutes()) {
|
||||||
|
// Skip non-unicast routes (which are all we get prior to Android 13)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && route.getType() != RouteInfo.RTN_UNICAST) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first route that matches this address
|
||||||
|
if (route.matches(serverAddress)) {
|
||||||
|
// If there's no gateway, this is an on-link destination
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// We want to use hasGateway() because getGateway() doesn't adhere
|
||||||
|
// to documented behavior of returning null for on-link addresses.
|
||||||
|
if (!route.hasGateway()) {
|
||||||
|
return StreamConfiguration.STREAM_CFG_LOCAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// getGateway() is documented to return null for on-link destinations,
|
||||||
|
// but it actually returns the unspecified address (0.0.0.0 or ::).
|
||||||
|
InetAddress gateway = route.getGateway();
|
||||||
|
if (gateway == null || gateway.isAnyLocalAddress()) {
|
||||||
|
return StreamConfiguration.STREAM_CFG_LOCAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We _should_ stop after the first matching route, but for some reason
|
||||||
|
// Android doesn't always report IPv6 routes in descending order of
|
||||||
|
// specificity and metric. To handle that case, we enumerate all matching
|
||||||
|
// routes, assuming that an on-link route will always be preferred.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo();
|
||||||
|
if (activeNetworkInfo != null) {
|
||||||
|
switch (activeNetworkInfo.getType()) {
|
||||||
|
case ConnectivityManager.TYPE_VPN:
|
||||||
|
case ConnectivityManager.TYPE_MOBILE:
|
||||||
|
case ConnectivityManager.TYPE_MOBILE_DUN:
|
||||||
|
case ConnectivityManager.TYPE_MOBILE_HIPRI:
|
||||||
|
case ConnectivityManager.TYPE_MOBILE_MMS:
|
||||||
|
case ConnectivityManager.TYPE_MOBILE_SUPL:
|
||||||
|
case ConnectivityManager.TYPE_WIMAX:
|
||||||
|
// VPNs and cellular connections are always remote connections
|
||||||
|
return StreamConfiguration.STREAM_CFG_REMOTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't determine the connection type, let moonlight-common-c decide.
|
||||||
|
return StreamConfiguration.STREAM_CFG_AUTO;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean startApp() throws XmlPullParserException, IOException
|
private boolean startApp() throws XmlPullParserException, IOException
|
||||||
{
|
{
|
||||||
NvHTTP h = new NvHTTP(context.serverAddress, uniqueId, context.serverCert, cryptoProvider);
|
NvHTTP h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, context.serverCert, cryptoProvider);
|
||||||
|
|
||||||
String serverInfo = h.getServerInfo();
|
String serverInfo = h.getServerInfo(true);
|
||||||
|
|
||||||
context.serverAppVersion = h.getServerVersion(serverInfo);
|
context.serverAppVersion = h.getServerVersion(serverInfo);
|
||||||
if (context.serverAppVersion == null) {
|
if (context.serverAppVersion == null) {
|
||||||
@@ -95,6 +231,9 @@ public class NvConnection {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ComputerDetails details = h.getComputerDetails(serverInfo);
|
||||||
|
context.isNvidiaServerSoftware = details.nvidiaServer;
|
||||||
|
|
||||||
// May be missing for older servers
|
// May be missing for older servers
|
||||||
context.serverGfeVersion = h.getGfeVersion(serverInfo);
|
context.serverGfeVersion = h.getGfeVersion(serverInfo);
|
||||||
|
|
||||||
@@ -103,9 +242,11 @@ public class NvConnection {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.negotiatedHdr = context.streamConfig.getEnableHdr();
|
context.serverCodecModeSupport = (int)h.getServerCodecModeSupport(serverInfo);
|
||||||
if ((h.getServerCodecModeSupport(serverInfo) & 0x200) == 0 && context.negotiatedHdr) {
|
|
||||||
context.connListener.displayTransientMessage("Your GPU does not support streaming HDR. The stream will be SDR.");
|
context.negotiatedHdr = (context.streamConfig.getSupportedVideoFormats() & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0;
|
||||||
|
if ((context.serverCodecModeSupport & 0x20200) == 0 && context.negotiatedHdr) {
|
||||||
|
context.connListener.displayTransientMessage("Your PC GPU does not support streaming HDR. The stream will be SDR.");
|
||||||
context.negotiatedHdr = false;
|
context.negotiatedHdr = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +255,17 @@ public class NvConnection {
|
|||||||
//
|
//
|
||||||
|
|
||||||
// Check for a supported stream resolution
|
// Check for a supported stream resolution
|
||||||
if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) {
|
if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) &&
|
||||||
|
(h.getServerCodecModeSupport(serverInfo) & 0x200) == 0 && context.isNvidiaServerSoftware) {
|
||||||
|
context.connListener.displayMessage("Your host PC does not support streaming at resolutions above 4K.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) &&
|
||||||
|
(context.streamConfig.getSupportedVideoFormats() & ~MoonBridge.VIDEO_FORMAT_MASK_H264) == 0) {
|
||||||
|
context.connListener.displayMessage("Your streaming device must support HEVC or AV1 to stream at resolutions above 4K.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) {
|
||||||
// Client wants 4K but the server can't do it
|
// Client wants 4K but the server can't do it
|
||||||
context.connListener.displayTransientMessage("You must update GeForce Experience to stream in 4K. The stream will be 1080p.");
|
context.connListener.displayTransientMessage("You must update GeForce Experience to stream in 4K. The stream will be 1080p.");
|
||||||
|
|
||||||
@@ -127,6 +278,18 @@ public class NvConnection {
|
|||||||
context.negotiatedWidth = context.streamConfig.getWidth();
|
context.negotiatedWidth = context.streamConfig.getWidth();
|
||||||
context.negotiatedHeight = context.streamConfig.getHeight();
|
context.negotiatedHeight = context.streamConfig.getHeight();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We will perform some connection type detection if the caller asked for it
|
||||||
|
if (context.streamConfig.getRemote() == StreamConfiguration.STREAM_CFG_AUTO) {
|
||||||
|
context.negotiatedRemoteStreaming = detectServerConnectionType();
|
||||||
|
context.negotiatedPacketSize =
|
||||||
|
context.negotiatedRemoteStreaming == StreamConfiguration.STREAM_CFG_REMOTE ?
|
||||||
|
1024 : context.streamConfig.getMaxPacketSize();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
context.negotiatedRemoteStreaming = context.streamConfig.getRemote();
|
||||||
|
context.negotiatedPacketSize = context.streamConfig.getMaxPacketSize();
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Video stream format will be decided during the RTSP handshake
|
// Video stream format will be decided during the RTSP handshake
|
||||||
@@ -148,14 +311,14 @@ public class NvConnection {
|
|||||||
if (h.getCurrentGame(serverInfo) != 0) {
|
if (h.getCurrentGame(serverInfo) != 0) {
|
||||||
try {
|
try {
|
||||||
if (h.getCurrentGame(serverInfo) == app.getAppId()) {
|
if (h.getCurrentGame(serverInfo) == app.getAppId()) {
|
||||||
if (!h.resumeApp(context)) {
|
if (!h.launchApp(context, "resume", app.getAppId(), context.negotiatedHdr)) {
|
||||||
context.connListener.displayMessage("Failed to resume existing session");
|
context.connListener.displayMessage("Failed to resume existing session");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return quitAndLaunch(h, context);
|
return quitAndLaunch(h, context);
|
||||||
}
|
}
|
||||||
} catch (GfeHttpResponseException e) {
|
} catch (HostHttpResponseException e) {
|
||||||
if (e.getErrorCode() == 470) {
|
if (e.getErrorCode() == 470) {
|
||||||
// This is the error you get when you try to resume a session that's not yours.
|
// This is the error you get when you try to resume a session that's not yours.
|
||||||
// Because this is fairly common, we'll display a more detailed message.
|
// Because this is fairly common, we'll display a more detailed message.
|
||||||
@@ -188,7 +351,7 @@ public class NvConnection {
|
|||||||
context.connListener.displayMessage("Failed to quit previous session! You must quit it manually");
|
context.connListener.displayMessage("Failed to quit previous session! You must quit it manually");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (GfeHttpResponseException e) {
|
} catch (HostHttpResponseException e) {
|
||||||
if (e.getErrorCode() == 599) {
|
if (e.getErrorCode() == 599) {
|
||||||
context.connListener.displayMessage("This session wasn't started by this device," +
|
context.connListener.displayMessage("This session wasn't started by this device," +
|
||||||
" so it cannot be quit. End streaming on the original " +
|
" so it cannot be quit. End streaming on the original " +
|
||||||
@@ -206,7 +369,7 @@ public class NvConnection {
|
|||||||
private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context)
|
private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context)
|
||||||
throws IOException, XmlPullParserException {
|
throws IOException, XmlPullParserException {
|
||||||
// Launch the app since it's not running
|
// Launch the app since it's not running
|
||||||
if (!h.launchApp(context, context.streamConfig.getApp().getAppId(), context.negotiatedHdr)) {
|
if (!h.launchApp(context, "launch", context.streamConfig.getApp().getAppId(), context.negotiatedHdr)) {
|
||||||
context.connListener.displayMessage("Failed to launch application");
|
context.connListener.displayMessage("Failed to launch application");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -225,23 +388,23 @@ public class NvConnection {
|
|||||||
|
|
||||||
String appName = context.streamConfig.getApp().getAppName();
|
String appName = context.streamConfig.getApp().getAppName();
|
||||||
|
|
||||||
context.serverAddress = host;
|
|
||||||
context.connListener.stageStarting(appName);
|
context.connListener.stageStarting(appName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!startApp()) {
|
if (!startApp()) {
|
||||||
context.connListener.stageFailed(appName, 0);
|
context.connListener.stageFailed(appName, 0, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.connListener.stageComplete(appName);
|
context.connListener.stageComplete(appName);
|
||||||
} catch (GfeHttpResponseException e) {
|
} catch (HostHttpResponseException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
context.connListener.displayMessage(e.getMessage());
|
context.connListener.displayMessage(e.getMessage());
|
||||||
context.connListener.stageFailed(appName, e.getErrorCode());
|
context.connListener.stageFailed(appName, 0, e.getErrorCode());
|
||||||
|
return;
|
||||||
} catch (XmlPullParserException | IOException e) {
|
} catch (XmlPullParserException | IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
context.connListener.displayMessage(e.getMessage());
|
context.connListener.displayMessage(e.getMessage());
|
||||||
context.connListener.stageFailed(appName, 0);
|
context.connListener.stageFailed(appName, MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +417,7 @@ public class NvConnection {
|
|||||||
connectionAllowed.acquire();
|
connectionAllowed.acquire();
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
context.connListener.displayMessage(e.getMessage());
|
context.connListener.displayMessage(e.getMessage());
|
||||||
context.connListener.stageFailed(appName, 0);
|
context.connListener.stageFailed(appName, 0, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,23 +425,25 @@ public class NvConnection {
|
|||||||
// we must not invoke that functionality in parallel.
|
// we must not invoke that functionality in parallel.
|
||||||
synchronized (MoonBridge.class) {
|
synchronized (MoonBridge.class) {
|
||||||
MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener);
|
MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener);
|
||||||
int ret = MoonBridge.startConnection(context.serverAddress,
|
int ret = MoonBridge.startConnection(context.serverAddress.address,
|
||||||
context.serverAppVersion, context.serverGfeVersion,
|
context.serverAppVersion, context.serverGfeVersion, context.rtspSessionUrl,
|
||||||
|
context.serverCodecModeSupport,
|
||||||
context.negotiatedWidth, context.negotiatedHeight,
|
context.negotiatedWidth, context.negotiatedHeight,
|
||||||
context.streamConfig.getRefreshRate(), context.streamConfig.getBitrate(),
|
context.streamConfig.getRefreshRate(), context.streamConfig.getBitrate(),
|
||||||
context.streamConfig.getMaxPacketSize(),
|
context.negotiatedPacketSize, context.negotiatedRemoteStreaming,
|
||||||
context.streamConfig.getRemote(), context.streamConfig.getAudioConfiguration().toInt(),
|
context.streamConfig.getAudioConfiguration().toInt(),
|
||||||
context.streamConfig.getHevcSupported(),
|
context.streamConfig.getSupportedVideoFormats(),
|
||||||
context.negotiatedHdr,
|
|
||||||
context.streamConfig.getHevcBitratePercentageMultiplier(),
|
|
||||||
context.streamConfig.getClientRefreshRateX100(),
|
context.streamConfig.getClientRefreshRateX100(),
|
||||||
context.riKey.getEncoded(), ib.array(),
|
context.riKey.getEncoded(), ib.array(),
|
||||||
context.videoCapabilities);
|
context.videoCapabilities,
|
||||||
|
context.streamConfig.getColorSpace(),
|
||||||
|
context.streamConfig.getColorRange());
|
||||||
if (ret != 0) {
|
if (ret != 0) {
|
||||||
// LiStartConnection() failed, so the caller is not expected
|
// LiStartConnection() failed, so the caller is not expected
|
||||||
// to stop the connection themselves. We need to release their
|
// to stop the connection themselves. We need to release their
|
||||||
// semaphore count for them.
|
// semaphore count for them.
|
||||||
connectionAllowed.release();
|
connectionAllowed.release();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +463,14 @@ public class NvConnection {
|
|||||||
MoonBridge.sendMousePosition(x, y, referenceWidth, referenceHeight);
|
MoonBridge.sendMousePosition(x, y, referenceWidth, referenceHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight)
|
||||||
|
{
|
||||||
|
if (!isMonkey) {
|
||||||
|
MoonBridge.sendMouseMoveAsMousePosition(deltaX, deltaY, referenceWidth, referenceHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void sendMouseButtonDown(final byte mouseButton)
|
public void sendMouseButtonDown(final byte mouseButton)
|
||||||
{
|
{
|
||||||
if (!isMonkey) {
|
if (!isMonkey) {
|
||||||
@@ -314,7 +486,7 @@ public class NvConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void sendControllerInput(final short controllerNumber,
|
public void sendControllerInput(final short controllerNumber,
|
||||||
final short activeGamepadMask, final short buttonFlags,
|
final short activeGamepadMask, final int buttonFlags,
|
||||||
final byte leftTrigger, final byte rightTrigger,
|
final byte leftTrigger, final byte rightTrigger,
|
||||||
final short leftStickX, final short leftStickY,
|
final short leftStickX, final short leftStickY,
|
||||||
final short rightStickX, final short rightStickY)
|
final short rightStickX, final short rightStickY)
|
||||||
@@ -324,27 +496,22 @@ public class NvConnection {
|
|||||||
leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY);
|
leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendControllerInput(final short buttonFlags,
|
public void sendKeyboardInput(final short keyMap, final byte keyDirection, final byte modifier, final byte flags) {
|
||||||
final byte leftTrigger, final byte rightTrigger,
|
|
||||||
final short leftStickX, final short leftStickY,
|
|
||||||
final short rightStickX, final short rightStickY)
|
|
||||||
{
|
|
||||||
if (!isMonkey) {
|
if (!isMonkey) {
|
||||||
MoonBridge.sendControllerInput(buttonFlags, leftTrigger, rightTrigger, leftStickX,
|
MoonBridge.sendKeyboardInput(keyMap, keyDirection, modifier, flags);
|
||||||
leftStickY, rightStickX, rightStickY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void sendKeyboardInput(final short keyMap, final byte keyDirection, final byte modifier) {
|
|
||||||
if (!isMonkey) {
|
|
||||||
MoonBridge.sendKeyboardInput(keyMap, keyDirection, modifier);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendMouseScroll(final byte scrollClicks) {
|
public void sendMouseScroll(final byte scrollClicks) {
|
||||||
if (!isMonkey) {
|
if (!isMonkey) {
|
||||||
MoonBridge.sendMouseScroll(scrollClicks);
|
MoonBridge.sendMouseHighResScroll((short)(scrollClicks * 120)); // WHEEL_DELTA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendMouseHScroll(final byte scrollClicks) {
|
||||||
|
if (!isMonkey) {
|
||||||
|
MoonBridge.sendMouseHighResHScroll((short)(scrollClicks * 120)); // WHEEL_DELTA
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +521,70 @@ public class NvConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void sendMouseHighResHScroll(final short scrollAmount) {
|
||||||
|
if (!isMonkey) {
|
||||||
|
MoonBridge.sendMouseHighResHScroll(scrollAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressureOrDistance,
|
||||||
|
float contactAreaMajor, float contactAreaMinor, short rotation) {
|
||||||
|
if (!isMonkey) {
|
||||||
|
return MoonBridge.sendTouchEvent(eventType, pointerId, x, y, pressureOrDistance,
|
||||||
|
contactAreaMajor, contactAreaMinor, rotation);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return MoonBridge.LI_ERR_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y,
|
||||||
|
float pressureOrDistance, float contactAreaMajor, float contactAreaMinor,
|
||||||
|
short rotation, byte tilt) {
|
||||||
|
if (!isMonkey) {
|
||||||
|
return MoonBridge.sendPenEvent(eventType, toolType, penButtons, x, y, pressureOrDistance,
|
||||||
|
contactAreaMajor, contactAreaMinor, rotation, tilt);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return MoonBridge.LI_ERR_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type,
|
||||||
|
int supportedButtonFlags, short capabilities) {
|
||||||
|
return MoonBridge.sendControllerArrivalEvent(controllerNumber, activeGamepadMask, type, supportedButtonFlags, capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId,
|
||||||
|
float x, float y, float pressure) {
|
||||||
|
if (!isMonkey) {
|
||||||
|
return MoonBridge.sendControllerTouchEvent(controllerNumber, eventType, pointerId, x, y, pressure);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return MoonBridge.LI_ERR_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int sendControllerMotionEvent(byte controllerNumber, byte motionType,
|
||||||
|
float x, float y, float z) {
|
||||||
|
if (!isMonkey) {
|
||||||
|
return MoonBridge.sendControllerMotionEvent(controllerNumber, motionType, x, y, z);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return MoonBridge.LI_ERR_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage) {
|
||||||
|
MoonBridge.sendControllerBatteryEvent(controllerNumber, batteryState, batteryPercentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendUtf8Text(final String text) {
|
||||||
|
if (!isMonkey) {
|
||||||
|
MoonBridge.sendUtf8Text(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static String findExternalAddressForMdns(String stunHostname, int stunPort) {
|
public static String findExternalAddressForMdns(String stunHostname, int stunPort) {
|
||||||
return MoonBridge.findExternalAddressIP4(stunHostname, stunPort);
|
return MoonBridge.findExternalAddressIP4(stunHostname, stunPort);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package com.limelight.nvstream;
|
|||||||
public interface NvConnectionListener {
|
public interface NvConnectionListener {
|
||||||
void stageStarting(String stage);
|
void stageStarting(String stage);
|
||||||
void stageComplete(String stage);
|
void stageComplete(String stage);
|
||||||
void stageFailed(String stage, int errorCode);
|
void stageFailed(String stage, int portFlags, int errorCode);
|
||||||
|
|
||||||
void connectionStarted();
|
void connectionStarted();
|
||||||
void connectionTerminated(int errorCode);
|
void connectionTerminated(int errorCode);
|
||||||
@@ -13,4 +13,11 @@ public interface NvConnectionListener {
|
|||||||
void displayTransientMessage(String message);
|
void displayTransientMessage(String message);
|
||||||
|
|
||||||
void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor);
|
void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor);
|
||||||
|
void rumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger);
|
||||||
|
|
||||||
|
void setHdrMode(boolean enabled, byte[] hdrMetadata);
|
||||||
|
|
||||||
|
void setMotionEventState(short controllerNumber, byte motionType, short reportRateHz);
|
||||||
|
|
||||||
|
void setControllerLED(short controllerNumber, byte r, byte g, byte b);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ public class StreamConfiguration {
|
|||||||
private int maxPacketSize;
|
private int maxPacketSize;
|
||||||
private int remote;
|
private int remote;
|
||||||
private MoonBridge.AudioConfiguration audioConfiguration;
|
private MoonBridge.AudioConfiguration audioConfiguration;
|
||||||
private boolean supportsHevc;
|
private int supportedVideoFormats;
|
||||||
private int hevcBitratePercentageMultiplier;
|
|
||||||
private boolean enableHdr;
|
|
||||||
private int attachedGamepadMask;
|
private int attachedGamepadMask;
|
||||||
|
private int encryptionFlags;
|
||||||
|
private int colorRange;
|
||||||
|
private int colorSpace;
|
||||||
|
private boolean persistGamepadsAfterDisconnect;
|
||||||
|
|
||||||
public static class Builder {
|
public static class Builder {
|
||||||
private StreamConfiguration config = new StreamConfiguration();
|
private StreamConfiguration config = new StreamConfiguration();
|
||||||
@@ -81,16 +83,6 @@ public class StreamConfiguration {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public StreamConfiguration.Builder setHevcBitratePercentageMultiplier(int multiplier) {
|
|
||||||
config.hevcBitratePercentageMultiplier = multiplier;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StreamConfiguration.Builder setEnableHdr(boolean enableHdr) {
|
|
||||||
config.enableHdr = enableHdr;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StreamConfiguration.Builder setAttachedGamepadMask(int attachedGamepadMask) {
|
public StreamConfiguration.Builder setAttachedGamepadMask(int attachedGamepadMask) {
|
||||||
config.attachedGamepadMask = attachedGamepadMask;
|
config.attachedGamepadMask = attachedGamepadMask;
|
||||||
return this;
|
return this;
|
||||||
@@ -106,21 +98,36 @@ public class StreamConfiguration {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public StreamConfiguration.Builder setPersistGamepadsAfterDisconnect(boolean value) {
|
||||||
|
config.persistGamepadsAfterDisconnect = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public StreamConfiguration.Builder setClientRefreshRateX100(int refreshRateX100) {
|
public StreamConfiguration.Builder setClientRefreshRateX100(int refreshRateX100) {
|
||||||
config.clientRefreshRateX100 = refreshRateX100;
|
config.clientRefreshRateX100 = refreshRateX100;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public StreamConfiguration.Builder setAudioConfiguration(MoonBridge.AudioConfiguration audioConfig) {
|
public StreamConfiguration.Builder setAudioConfiguration(MoonBridge.AudioConfiguration audioConfig) {
|
||||||
config.audioConfiguration = audioConfig;
|
config.audioConfiguration = audioConfig;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public StreamConfiguration.Builder setHevcSupported(boolean supportsHevc) {
|
public StreamConfiguration.Builder setSupportedVideoFormats(int supportedVideoFormats) {
|
||||||
config.supportsHevc = supportsHevc;
|
config.supportedVideoFormats = supportedVideoFormats;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public StreamConfiguration.Builder setColorRange(int colorRange) {
|
||||||
|
config.colorRange = colorRange;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StreamConfiguration.Builder setColorSpace(int colorSpace) {
|
||||||
|
config.colorSpace = colorSpace;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public StreamConfiguration build() {
|
public StreamConfiguration build() {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
@@ -139,8 +146,7 @@ public class StreamConfiguration {
|
|||||||
this.sops = true;
|
this.sops = true;
|
||||||
this.enableAdaptiveResolution = false;
|
this.enableAdaptiveResolution = false;
|
||||||
this.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO;
|
this.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO;
|
||||||
this.supportsHevc = false;
|
this.supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264;
|
||||||
this.enableHdr = false;
|
|
||||||
this.attachedGamepadMask = 0;
|
this.attachedGamepadMask = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,23 +198,27 @@ public class StreamConfiguration {
|
|||||||
return audioConfiguration;
|
return audioConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean getHevcSupported() {
|
public int getSupportedVideoFormats() {
|
||||||
return supportsHevc;
|
return supportedVideoFormats;
|
||||||
}
|
|
||||||
|
|
||||||
public int getHevcBitratePercentageMultiplier() {
|
|
||||||
return hevcBitratePercentageMultiplier;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean getEnableHdr() {
|
|
||||||
return enableHdr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getAttachedGamepadMask() {
|
public int getAttachedGamepadMask() {
|
||||||
return attachedGamepadMask;
|
return attachedGamepadMask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean getPersistGamepadsAfterDisconnect() {
|
||||||
|
return persistGamepadsAfterDisconnect;
|
||||||
|
}
|
||||||
|
|
||||||
public int getClientRefreshRateX100() {
|
public int getClientRefreshRateX100() {
|
||||||
return clientRefreshRateX100;
|
return clientRefreshRateX100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getColorRange() {
|
||||||
|
return colorRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getColorSpace() {
|
||||||
|
return colorSpace;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ public abstract class VideoDecoderRenderer {
|
|||||||
// This is called once for each frame-start NALU. This means it will be called several times
|
// This is called once for each frame-start NALU. This means it will be called several times
|
||||||
// for an IDR frame which contains several parameter sets and the I-frame data.
|
// for an IDR frame which contains several parameter sets and the I-frame data.
|
||||||
public abstract int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
|
public abstract int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
|
||||||
int frameNumber, long receiveTimeMs);
|
int frameNumber, int frameType, char frameHostProcessingLatency,
|
||||||
|
long receiveTimeMs, long enqueueTimeMs);
|
||||||
|
|
||||||
public abstract void cleanup();
|
public abstract void cleanup();
|
||||||
|
|
||||||
public abstract int getCapabilities();
|
public abstract int getCapabilities();
|
||||||
|
|
||||||
|
public abstract void setHdrMode(boolean enabled, byte[] hdrMetadata);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.limelight.nvstream.http;
|
package com.limelight.nvstream.http;
|
||||||
|
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
|
||||||
public class ComputerDetails {
|
public class ComputerDetails {
|
||||||
@@ -8,22 +9,73 @@ public class ComputerDetails {
|
|||||||
ONLINE, OFFLINE, UNKNOWN
|
ONLINE, OFFLINE, UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class AddressTuple {
|
||||||
|
public String address;
|
||||||
|
public int port;
|
||||||
|
|
||||||
|
public AddressTuple(String address, int port) {
|
||||||
|
if (address == null) {
|
||||||
|
throw new IllegalArgumentException("Address cannot be null");
|
||||||
|
}
|
||||||
|
if (port <= 0) {
|
||||||
|
throw new IllegalArgumentException("Invalid port");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this was an escaped IPv6 address, remove the brackets
|
||||||
|
if (address.startsWith("[") && address.endsWith("]")) {
|
||||||
|
address = address.substring(1, address.length() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.address = address;
|
||||||
|
this.port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(address, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (!(obj instanceof AddressTuple)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressTuple that = (AddressTuple) obj;
|
||||||
|
return address.equals(that.address) && port == that.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toString() {
|
||||||
|
if (address.contains(":")) {
|
||||||
|
// IPv6
|
||||||
|
return "[" + address + "]:" + port;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// IPv4 and hostnames
|
||||||
|
return address + ":" + port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Persistent attributes
|
// Persistent attributes
|
||||||
public String uuid;
|
public String uuid;
|
||||||
public String name;
|
public String name;
|
||||||
public String localAddress;
|
public AddressTuple localAddress;
|
||||||
public String remoteAddress;
|
public AddressTuple remoteAddress;
|
||||||
public String manualAddress;
|
public AddressTuple manualAddress;
|
||||||
public String ipv6Address;
|
public AddressTuple ipv6Address;
|
||||||
public String macAddress;
|
public String macAddress;
|
||||||
public X509Certificate serverCert;
|
public X509Certificate serverCert;
|
||||||
|
|
||||||
// Transient attributes
|
// Transient attributes
|
||||||
public State state;
|
public State state;
|
||||||
public String activeAddress;
|
public AddressTuple activeAddress;
|
||||||
|
public int httpsPort;
|
||||||
|
public int externalPort;
|
||||||
public PairingManager.PairState pairState;
|
public PairingManager.PairState pairState;
|
||||||
public int runningGameId;
|
public int runningGameId;
|
||||||
public String rawAppList;
|
public String rawAppList;
|
||||||
|
public boolean nvidiaServer;
|
||||||
|
|
||||||
public ComputerDetails() {
|
public ComputerDetails() {
|
||||||
// Use defaults
|
// Use defaults
|
||||||
@@ -35,6 +87,27 @@ public class ComputerDetails {
|
|||||||
update(details);
|
update(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int guessExternalPort() {
|
||||||
|
if (externalPort != 0) {
|
||||||
|
return externalPort;
|
||||||
|
}
|
||||||
|
else if (remoteAddress != null) {
|
||||||
|
return remoteAddress.port;
|
||||||
|
}
|
||||||
|
else if (activeAddress != null) {
|
||||||
|
return activeAddress.port;
|
||||||
|
}
|
||||||
|
else if (ipv6Address != null) {
|
||||||
|
return ipv6Address.port;
|
||||||
|
}
|
||||||
|
else if (localAddress != null) {
|
||||||
|
return localAddress.port;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return NvHTTP.DEFAULT_HTTP_PORT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void update(ComputerDetails details) {
|
public void update(ComputerDetails details) {
|
||||||
this.state = details.state;
|
this.state = details.state;
|
||||||
this.name = details.name;
|
this.name = details.name;
|
||||||
@@ -43,12 +116,18 @@ public class ComputerDetails {
|
|||||||
this.activeAddress = details.activeAddress;
|
this.activeAddress = details.activeAddress;
|
||||||
}
|
}
|
||||||
// We can get IPv4 loopback addresses with GS IPv6 Forwarder
|
// We can get IPv4 loopback addresses with GS IPv6 Forwarder
|
||||||
if (details.localAddress != null && !details.localAddress.startsWith("127.")) {
|
if (details.localAddress != null && !details.localAddress.address.startsWith("127.")) {
|
||||||
this.localAddress = details.localAddress;
|
this.localAddress = details.localAddress;
|
||||||
}
|
}
|
||||||
if (details.remoteAddress != null) {
|
if (details.remoteAddress != null) {
|
||||||
this.remoteAddress = details.remoteAddress;
|
this.remoteAddress = details.remoteAddress;
|
||||||
}
|
}
|
||||||
|
else if (this.remoteAddress != null && details.externalPort != 0) {
|
||||||
|
// If we have a remote address already (perhaps via STUN) but our updated details
|
||||||
|
// don't have a new one (because GFE doesn't send one), propagate the external
|
||||||
|
// port to the current remote address. We may have tried to guess it previously.
|
||||||
|
this.remoteAddress.port = details.externalPort;
|
||||||
|
}
|
||||||
if (details.manualAddress != null) {
|
if (details.manualAddress != null) {
|
||||||
this.manualAddress = details.manualAddress;
|
this.manualAddress = details.manualAddress;
|
||||||
}
|
}
|
||||||
@@ -61,17 +140,20 @@ public class ComputerDetails {
|
|||||||
if (details.serverCert != null) {
|
if (details.serverCert != null) {
|
||||||
this.serverCert = details.serverCert;
|
this.serverCert = details.serverCert;
|
||||||
}
|
}
|
||||||
|
this.externalPort = details.externalPort;
|
||||||
|
this.httpsPort = details.httpsPort;
|
||||||
this.pairState = details.pairState;
|
this.pairState = details.pairState;
|
||||||
this.runningGameId = details.runningGameId;
|
this.runningGameId = details.runningGameId;
|
||||||
|
this.nvidiaServer = details.nvidiaServer;
|
||||||
this.rawAppList = details.rawAppList;
|
this.rawAppList = details.rawAppList;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
StringBuilder str = new StringBuilder();
|
StringBuilder str = new StringBuilder();
|
||||||
|
str.append("Name: ").append(name).append("\n");
|
||||||
str.append("State: ").append(state).append("\n");
|
str.append("State: ").append(state).append("\n");
|
||||||
str.append("Active Address: ").append(activeAddress).append("\n");
|
str.append("Active Address: ").append(activeAddress).append("\n");
|
||||||
str.append("Name: ").append(name).append("\n");
|
|
||||||
str.append("UUID: ").append(uuid).append("\n");
|
str.append("UUID: ").append(uuid).append("\n");
|
||||||
str.append("Local Address: ").append(localAddress).append("\n");
|
str.append("Local Address: ").append(localAddress).append("\n");
|
||||||
str.append("Remote Address: ").append(remoteAddress).append("\n");
|
str.append("Remote Address: ").append(remoteAddress).append("\n");
|
||||||
@@ -80,6 +162,7 @@ public class ComputerDetails {
|
|||||||
str.append("MAC Address: ").append(macAddress).append("\n");
|
str.append("MAC Address: ").append(macAddress).append("\n");
|
||||||
str.append("Pair State: ").append(pairState).append("\n");
|
str.append("Pair State: ").append(pairState).append("\n");
|
||||||
str.append("Running Game ID: ").append(runningGameId).append("\n");
|
str.append("Running Game ID: ").append(runningGameId).append("\n");
|
||||||
|
str.append("HTTPS Port: ").append(httpsPort).append("\n");
|
||||||
return str.toString();
|
return str.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -2,13 +2,13 @@ package com.limelight.nvstream.http;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
public class GfeHttpResponseException extends IOException {
|
public class HostHttpResponseException extends IOException {
|
||||||
private static final long serialVersionUID = 1543508830807804222L;
|
private static final long serialVersionUID = 1543508830807804222L;
|
||||||
|
|
||||||
private int errorCode;
|
private int errorCode;
|
||||||
private String errorMsg;
|
private String errorMsg;
|
||||||
|
|
||||||
public GfeHttpResponseException(int errorCode, String errorMsg) {
|
public HostHttpResponseException(int errorCode, String errorMsg) {
|
||||||
this.errorCode = errorCode;
|
this.errorCode = errorCode;
|
||||||
this.errorMsg = errorMsg;
|
this.errorMsg = errorMsg;
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,6 @@ public class GfeHttpResponseException extends IOException {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getMessage() {
|
public String getMessage() {
|
||||||
return "GeForce Experience returned error: "+errorMsg+" (Error code: "+errorCode+")";
|
return "Host PC returned error: "+errorMsg+" (Error code: "+errorCode+")";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.limelight.nvstream.http;
|
package com.limelight.nvstream.http;
|
||||||
|
|
||||||
|
import java.security.PrivateKey;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.security.interfaces.RSAPrivateKey;
|
|
||||||
|
|
||||||
public interface LimelightCryptoProvider {
|
public interface LimelightCryptoProvider {
|
||||||
X509Certificate getClientCertificate();
|
X509Certificate getClientCertificate();
|
||||||
RSAPrivateKey getClientPrivateKey();
|
PrivateKey getClientPrivateKey();
|
||||||
byte[] getPemEncodedClientCertificate();
|
byte[] getPemEncodedClientCertificate();
|
||||||
String encodeBase64String(byte[] data);
|
String encodeBase64String(byte[] data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,4 +58,13 @@ public class NvApp {
|
|||||||
public boolean isInitialized() {
|
public boolean isInitialized() {
|
||||||
return this.initialized;
|
return this.initialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
StringBuilder str = new StringBuilder();
|
||||||
|
str.append("Name: ").append(appName).append("\n");
|
||||||
|
str.append("HDR Supported: ").append(hdrSupported ? "Yes" : "Unknown").append("\n");
|
||||||
|
str.append("ID: ").append(appId).append("\n");
|
||||||
|
return str.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
package com.limelight.nvstream.http;
|
package com.limelight.nvstream.http;
|
||||||
|
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.Reader;
|
import java.io.Reader;
|
||||||
import java.io.StringReader;
|
import java.io.StringReader;
|
||||||
import java.net.MalformedURLException;
|
import java.net.Inet4Address;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.Proxy;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.security.KeyManagementException;
|
import java.security.KeyManagementException;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.security.KeyStoreException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.security.PrivateKey;
|
import java.security.PrivateKey;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
|
import java.security.cert.Certificate;
|
||||||
import java.security.cert.CertificateException;
|
import java.security.cert.CertificateException;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
@@ -24,11 +29,16 @@ import java.util.UUID;
|
|||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import javax.net.ssl.HostnameVerifier;
|
import javax.net.ssl.HostnameVerifier;
|
||||||
|
import javax.net.ssl.HttpsURLConnection;
|
||||||
import javax.net.ssl.KeyManager;
|
import javax.net.ssl.KeyManager;
|
||||||
import javax.net.ssl.SSLContext;
|
import javax.net.ssl.SSLContext;
|
||||||
import javax.net.ssl.SSLHandshakeException;
|
import javax.net.ssl.SSLHandshakeException;
|
||||||
|
import javax.net.ssl.SSLPeerUnverifiedException;
|
||||||
import javax.net.ssl.SSLSession;
|
import javax.net.ssl.SSLSession;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
import javax.net.ssl.SSLSocketFactory;
|
||||||
import javax.net.ssl.TrustManager;
|
import javax.net.ssl.TrustManager;
|
||||||
|
import javax.net.ssl.TrustManagerFactory;
|
||||||
import javax.net.ssl.X509KeyManager;
|
import javax.net.ssl.X509KeyManager;
|
||||||
import javax.net.ssl.X509TrustManager;
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
|
||||||
@@ -40,9 +50,10 @@ import com.limelight.BuildConfig;
|
|||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
import com.limelight.nvstream.ConnectionContext;
|
import com.limelight.nvstream.ConnectionContext;
|
||||||
import com.limelight.nvstream.http.PairingManager.PairState;
|
import com.limelight.nvstream.http.PairingManager.PairState;
|
||||||
|
import com.limelight.nvstream.jni.MoonBridge;
|
||||||
|
|
||||||
import okhttp3.ConnectionPool;
|
import okhttp3.ConnectionPool;
|
||||||
import okhttp3.Handshake;
|
import okhttp3.HttpUrl;
|
||||||
import okhttp3.OkHttpClient;
|
import okhttp3.OkHttpClient;
|
||||||
import okhttp3.Request;
|
import okhttp3.Request;
|
||||||
import okhttp3.Response;
|
import okhttp3.Response;
|
||||||
@@ -53,47 +64,52 @@ public class NvHTTP {
|
|||||||
private String uniqueId;
|
private String uniqueId;
|
||||||
private PairingManager pm;
|
private PairingManager pm;
|
||||||
|
|
||||||
public static final int HTTPS_PORT = 47984;
|
private static final int DEFAULT_HTTPS_PORT = 47984;
|
||||||
public static final int HTTP_PORT = 47989;
|
public static final int DEFAULT_HTTP_PORT = 47989;
|
||||||
public static final int CONNECTION_TIMEOUT = 3000;
|
public static final int SHORT_CONNECTION_TIMEOUT = 3000;
|
||||||
public static final int READ_TIMEOUT = 5000;
|
public static final int LONG_CONNECTION_TIMEOUT = 5000;
|
||||||
|
public static final int READ_TIMEOUT = 7000;
|
||||||
|
|
||||||
// Print URL and content to logcat on debug builds
|
// Print URL and content to logcat on debug builds
|
||||||
private static boolean verbose = BuildConfig.DEBUG;
|
private static boolean verbose = BuildConfig.DEBUG;
|
||||||
|
|
||||||
public String baseUrlHttps;
|
private HttpUrl baseUrlHttp;
|
||||||
public String baseUrlHttp;
|
|
||||||
|
private int httpsPort;
|
||||||
|
|
||||||
private OkHttpClient httpClient;
|
private OkHttpClient httpClientLongConnectTimeout;
|
||||||
private OkHttpClient httpClientWithReadTimeout;
|
private OkHttpClient httpClientLongConnectNoReadTimeout;
|
||||||
|
private OkHttpClient httpClientShortConnectTimeout;
|
||||||
|
|
||||||
|
private X509TrustManager defaultTrustManager;
|
||||||
private X509TrustManager trustManager;
|
private X509TrustManager trustManager;
|
||||||
private X509KeyManager keyManager;
|
private X509KeyManager keyManager;
|
||||||
private X509Certificate serverCert;
|
private X509Certificate serverCert;
|
||||||
|
|
||||||
void setServerCert(X509Certificate serverCert) {
|
void setServerCert(X509Certificate serverCert) {
|
||||||
this.serverCert = serverCert;
|
this.serverCert = serverCert;
|
||||||
|
|
||||||
trustManager = new X509TrustManager() {
|
|
||||||
public X509Certificate[] getAcceptedIssuers() {
|
|
||||||
return new X509Certificate[0];
|
|
||||||
}
|
|
||||||
public void checkClientTrusted(X509Certificate[] certs, String authType) {
|
|
||||||
throw new IllegalStateException("Should never be called");
|
|
||||||
}
|
|
||||||
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
|
|
||||||
// Check the server certificate if we've paired to this host
|
|
||||||
if (!certs[0].equals(NvHTTP.this.serverCert)) {
|
|
||||||
throw new CertificateException("Certificate mismatch");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeHttpState(final X509Certificate serverCert, final LimelightCryptoProvider cryptoProvider) {
|
private static X509TrustManager getDefaultTrustManager() {
|
||||||
// Set up TrustManager
|
try {
|
||||||
setServerCert(serverCert);
|
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||||
|
tmf.init((KeyStore) null);
|
||||||
|
|
||||||
|
for (TrustManager tm : tmf.getTrustManagers()) {
|
||||||
|
if (tm instanceof X509TrustManager) {
|
||||||
|
return (X509TrustManager) tm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
} catch (KeyStoreException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException("No X509 trust manager found");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initializeHttpState(final LimelightCryptoProvider cryptoProvider) {
|
||||||
keyManager = new X509KeyManager() {
|
keyManager = new X509KeyManager() {
|
||||||
public String chooseClientAlias(String[] keyTypes,
|
public String chooseClientAlias(String[] keyTypes,
|
||||||
Principal[] issuers, Socket socket) { return "Limelight-RSA"; }
|
Principal[] issuers, Socket socket) { return "Limelight-RSA"; }
|
||||||
@@ -109,47 +125,118 @@ public class NvHTTP {
|
|||||||
public String[] getServerAliases(String keyType, Principal[] issuers) { return null; }
|
public String[] getServerAliases(String keyType, Principal[] issuers) { return null; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ignore differences between given hostname and certificate hostname
|
defaultTrustManager = getDefaultTrustManager();
|
||||||
HostnameVerifier hv = new HostnameVerifier() {
|
trustManager = new X509TrustManager() {
|
||||||
public boolean verify(String hostname, SSLSession session) { return true; }
|
public X509Certificate[] getAcceptedIssuers() {
|
||||||
|
return new X509Certificate[0];
|
||||||
|
}
|
||||||
|
public void checkClientTrusted(X509Certificate[] certs, String authType) {
|
||||||
|
throw new IllegalStateException("Should never be called");
|
||||||
|
}
|
||||||
|
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
|
||||||
|
try {
|
||||||
|
// Try the default trust manager first to allow pairing with certificates
|
||||||
|
// that chain up to a trusted root CA. This will raise CertificateException
|
||||||
|
// if the certificate is not trusted (expected for GFE's self-signed certs).
|
||||||
|
defaultTrustManager.checkServerTrusted(certs, authType);
|
||||||
|
} catch (CertificateException e) {
|
||||||
|
// Check the server certificate if we've paired to this host
|
||||||
|
if (certs.length == 1 && NvHTTP.this.serverCert != null) {
|
||||||
|
if (!certs[0].equals(NvHTTP.this.serverCert)) {
|
||||||
|
throw new CertificateException("Certificate mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// The cert chain doesn't look like a self-signed cert or we don't have
|
||||||
|
// a certificate pinned, so re-throw the original validation error.
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
httpClient = new OkHttpClient.Builder()
|
HostnameVerifier hv = new HostnameVerifier() {
|
||||||
|
public boolean verify(String hostname, SSLSession session) {
|
||||||
|
try {
|
||||||
|
Certificate[] certificates = session.getPeerCertificates();
|
||||||
|
if (certificates.length == 1 && certificates[0].equals(NvHTTP.this.serverCert)) {
|
||||||
|
// Allow any hostname if it's our pinned cert
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (SSLPeerUnverifiedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default HostnameVerifier for validating CA-issued certs
|
||||||
|
return HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
httpClientLongConnectTimeout = new OkHttpClient.Builder()
|
||||||
.connectionPool(new ConnectionPool(0, 1, TimeUnit.MILLISECONDS))
|
.connectionPool(new ConnectionPool(0, 1, TimeUnit.MILLISECONDS))
|
||||||
.hostnameVerifier(hv)
|
.hostnameVerifier(hv)
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
|
||||||
.connectTimeout(CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
httpClientWithReadTimeout = httpClient.newBuilder()
|
|
||||||
.readTimeout(READ_TIMEOUT, TimeUnit.MILLISECONDS)
|
.readTimeout(READ_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
.connectTimeout(LONG_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
.proxy(Proxy.NO_PROXY)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
httpClientShortConnectTimeout = httpClientLongConnectTimeout.newBuilder()
|
||||||
|
.connectTimeout(SHORT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
httpClientLongConnectNoReadTimeout = httpClientLongConnectTimeout.newBuilder()
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public HttpUrl getHttpsUrl(boolean likelyOnline) throws IOException {
|
||||||
|
if (httpsPort == 0) {
|
||||||
|
// Fetch the HTTPS port if we don't have it already
|
||||||
|
httpsPort = getHttpsPort(openHttpConnectionToString(likelyOnline ? httpClientLongConnectTimeout : httpClientShortConnectTimeout,
|
||||||
|
baseUrlHttp, "serverinfo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HttpUrl.Builder().scheme("https").host(baseUrlHttp.host()).port(httpsPort).build();
|
||||||
|
}
|
||||||
|
|
||||||
public NvHTTP(String address, String uniqueId, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException {
|
public NvHTTP(ComputerDetails.AddressTuple address, int httpsPort, String uniqueId, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException {
|
||||||
// Use the same UID for all Moonlight clients so we can quit games
|
// Use the same UID for all Moonlight clients so we can quit games
|
||||||
// started by other Moonlight clients.
|
// started by other Moonlight clients.
|
||||||
this.uniqueId = "0123456789ABCDEF";
|
this.uniqueId = "0123456789ABCDEF";
|
||||||
|
|
||||||
initializeHttpState(serverCert, cryptoProvider);
|
this.serverCert = serverCert;
|
||||||
|
|
||||||
|
initializeHttpState(cryptoProvider);
|
||||||
|
|
||||||
|
this.httpsPort = httpsPort;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The URI constructor takes care of escaping IPv6 literals
|
// If this is an IPv4-mapped IPv6 address, OkHTTP will choke on it if it's
|
||||||
this.baseUrlHttps = new URI("https", null, address, HTTPS_PORT, null, null, null).toString();
|
// in IPv6 form, because InetAddress.getByName() will return an Inet4Address
|
||||||
this.baseUrlHttp = new URI("http", null, address, HTTP_PORT, null, null, null).toString();
|
// for what OkHTTP thinks is an IPv6 address. Normalize it into IPv4 form
|
||||||
} catch (URISyntaxException e) {
|
// to avoid triggering this bug.
|
||||||
// Encapsulate URISyntaxException into IOException for callers to handle more easily
|
String addressString = address.address;
|
||||||
|
if (addressString.contains(":") && addressString.contains(".")) {
|
||||||
|
InetAddress addr = InetAddress.getByName(addressString);
|
||||||
|
if (addr instanceof Inet4Address) {
|
||||||
|
addressString = ((Inet4Address)addr).getHostAddress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.baseUrlHttp = new HttpUrl.Builder()
|
||||||
|
.scheme("http")
|
||||||
|
.host(addressString)
|
||||||
|
.port(address.port)
|
||||||
|
.build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// Encapsulate IllegalArgumentException into IOException for callers to handle more easily
|
||||||
throw new IOException(e);
|
throw new IOException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pm = new PairingManager(this, cryptoProvider);
|
this.pm = new PairingManager(this, cryptoProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
String buildUniqueIdUuidString() {
|
static String getXmlString(Reader r, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException {
|
||||||
return "uniqueid="+uniqueId+"&uuid="+UUID.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
static String getXmlString(Reader r, String tagname) throws XmlPullParserException, IOException {
|
|
||||||
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
|
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
|
||||||
factory.setNamespaceAware(true);
|
factory.setNamespaceAware(true);
|
||||||
XmlPullParser xpp = factory.newPullParser();
|
XmlPullParser xpp = factory.newPullParser();
|
||||||
@@ -171,21 +258,29 @@ public class NvHTTP {
|
|||||||
break;
|
break;
|
||||||
case (XmlPullParser.TEXT):
|
case (XmlPullParser.TEXT):
|
||||||
if (currentTag.peek().equals(tagname)) {
|
if (currentTag.peek().equals(tagname)) {
|
||||||
return xpp.getText().trim();
|
return xpp.getText();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
eventType = xpp.next();
|
eventType = xpp.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (throwIfMissing) {
|
||||||
|
// We throw an XmlPullParserException here for ease of handling in all the various callers.
|
||||||
|
// We could also throw an IOException, but some callers expect those in cases where the
|
||||||
|
// host may not be reachable. We want to distinguish unreachable hosts vs. hosts that
|
||||||
|
// are returning garbage XML to us, so we use XmlPullParserException instead.
|
||||||
|
throw new XmlPullParserException("Missing mandatory field in host response: "+tagname);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String getXmlString(String str, String tagname) throws XmlPullParserException, IOException {
|
static String getXmlString(String str, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException {
|
||||||
return getXmlString(new StringReader(str), tagname);
|
return getXmlString(new StringReader(str), tagname, throwIfMissing);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void verifyResponseStatus(XmlPullParser xpp) throws GfeHttpResponseException {
|
private static void verifyResponseStatus(XmlPullParser xpp) throws HostHttpResponseException {
|
||||||
// We use Long.parseLong() because in rare cases GFE can send back a status code of
|
// We use Long.parseLong() because in rare cases GFE can send back a status code of
|
||||||
// 0xFFFFFFFF, which will cause Integer.parseInt() to throw a NumberFormatException due
|
// 0xFFFFFFFF, which will cause Integer.parseInt() to throw a NumberFormatException due
|
||||||
// to exceeding Integer.MAX_VALUE. We'll get the desired error code of -1 by just casting
|
// to exceeding Integer.MAX_VALUE. We'll get the desired error code of -1 by just casting
|
||||||
@@ -199,12 +294,15 @@ public class NvHTTP {
|
|||||||
statusCode = 418;
|
statusCode = 418;
|
||||||
statusMsg = "Missing audio capture device. Reinstall GeForce Experience.";
|
statusMsg = "Missing audio capture device. Reinstall GeForce Experience.";
|
||||||
}
|
}
|
||||||
throw new GfeHttpResponseException(statusCode, statusMsg);
|
throw new HostHttpResponseException(statusCode, statusMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getServerInfo() throws IOException, XmlPullParserException {
|
public String getServerInfo(boolean likelyOnline) throws IOException, XmlPullParserException {
|
||||||
String resp;
|
String resp;
|
||||||
|
|
||||||
|
// If we believe the PC is online, give it a little extra time to respond
|
||||||
|
OkHttpClient client = likelyOnline ? httpClientLongConnectTimeout : httpClientShortConnectTimeout;
|
||||||
|
|
||||||
//
|
//
|
||||||
// TODO: Shield Hub uses HTTP for this and is able to get an accurate PairStatus with HTTP.
|
// TODO: Shield Hub uses HTTP for this and is able to get an accurate PairStatus with HTTP.
|
||||||
@@ -216,13 +314,13 @@ public class NvHTTP {
|
|||||||
if (serverCert != null) {
|
if (serverCert != null) {
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
resp = openHttpConnectionToString(baseUrlHttps + "/serverinfo?"+buildUniqueIdUuidString(), true);
|
resp = openHttpConnectionToString(client, getHttpsUrl(likelyOnline), "serverinfo");
|
||||||
} catch (SSLHandshakeException e) {
|
} catch (SSLHandshakeException e) {
|
||||||
// Detect if we failed due to a server cert mismatch
|
// Detect if we failed due to a server cert mismatch
|
||||||
if (e.getCause() instanceof CertificateException) {
|
if (e.getCause() instanceof CertificateException) {
|
||||||
// Jump to the GfeHttpResponseException exception handler to retry
|
// Jump to the GfeHttpResponseException exception handler to retry
|
||||||
// over HTTP which will allow us to pair again to update the cert
|
// over HTTP which will allow us to pair again to update the cert
|
||||||
throw new GfeHttpResponseException(401, "Server certificate mismatch");
|
throw new HostHttpResponseException(401, "Server certificate mismatch");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
throw e;
|
throw e;
|
||||||
@@ -233,10 +331,10 @@ public class NvHTTP {
|
|||||||
// We want this because it will throw us into the HTTP case if the client is unpaired.
|
// We want this because it will throw us into the HTTP case if the client is unpaired.
|
||||||
getServerVersion(resp);
|
getServerVersion(resp);
|
||||||
}
|
}
|
||||||
catch (GfeHttpResponseException e) {
|
catch (HostHttpResponseException e) {
|
||||||
if (e.getErrorCode() == 401) {
|
if (e.getErrorCode() == 401) {
|
||||||
// Cert validation error - fall back to HTTP
|
// Cert validation error - fall back to HTTP
|
||||||
return openHttpConnectionToString(baseUrlHttp + "/serverinfo", true);
|
return openHttpConnectionToString(client, baseUrlHttp, "serverinfo");
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's not a cert validation error, throw it
|
// If it's not a cert validation error, throw it
|
||||||
@@ -247,40 +345,55 @@ public class NvHTTP {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// No pinned cert, so use HTTP
|
// No pinned cert, so use HTTP
|
||||||
return openHttpConnectionToString(baseUrlHttp + "/serverinfo", true);
|
return openHttpConnectionToString(client, baseUrlHttp, "serverinfo");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ComputerDetails getComputerDetails() throws IOException, XmlPullParserException {
|
private static ComputerDetails.AddressTuple makeTuple(String address, int port) {
|
||||||
|
if (address == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ComputerDetails.AddressTuple(address, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComputerDetails getComputerDetails(String serverInfo) throws IOException, XmlPullParserException {
|
||||||
ComputerDetails details = new ComputerDetails();
|
ComputerDetails details = new ComputerDetails();
|
||||||
String serverInfo = getServerInfo();
|
|
||||||
|
details.name = getXmlString(serverInfo, "hostname", false);
|
||||||
details.name = getXmlString(serverInfo, "hostname");
|
|
||||||
if (details.name == null || details.name.isEmpty()) {
|
if (details.name == null || details.name.isEmpty()) {
|
||||||
details.name = "UNKNOWN";
|
details.name = "UNKNOWN";
|
||||||
}
|
}
|
||||||
|
|
||||||
details.uuid = getXmlString(serverInfo, "uniqueid");
|
// UUID is mandatory to determine which machine is responding
|
||||||
details.macAddress = getXmlString(serverInfo, "mac");
|
details.uuid = getXmlString(serverInfo, "uniqueid", true);
|
||||||
details.localAddress = getXmlString(serverInfo, "LocalIP");
|
|
||||||
|
|
||||||
// This may be null, but that's okay
|
details.httpsPort = getHttpsPort(serverInfo);
|
||||||
details.remoteAddress = getXmlString(serverInfo, "ExternalIP");
|
|
||||||
|
details.macAddress = getXmlString(serverInfo, "mac", false);
|
||||||
|
|
||||||
|
// FIXME: Do we want to use the current port?
|
||||||
|
details.localAddress = makeTuple(getXmlString(serverInfo, "LocalIP", false), baseUrlHttp.port());
|
||||||
|
|
||||||
|
// This is missing on on recent GFE versions, but it's present on Sunshine
|
||||||
|
details.externalPort = getExternalPort(serverInfo);
|
||||||
|
details.remoteAddress = makeTuple(getXmlString(serverInfo, "ExternalIP", false), details.externalPort);
|
||||||
|
|
||||||
// This has some extra logic to always report unpaired if the pinned cert isn't there
|
|
||||||
details.pairState = getPairState(serverInfo);
|
details.pairState = getPairState(serverInfo);
|
||||||
|
details.runningGameId = getCurrentGame(serverInfo);
|
||||||
try {
|
|
||||||
details.runningGameId = getCurrentGame(serverInfo);
|
// The MJOLNIR codename was used by GFE but never by any third-party server
|
||||||
} catch (NumberFormatException e) {
|
details.nvidiaServer = getXmlString(serverInfo, "state", true).contains("MJOLNIR");
|
||||||
details.runningGameId = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We could reach it so it's online
|
// We could reach it so it's online
|
||||||
details.state = ComputerDetails.State.ONLINE;
|
details.state = ComputerDetails.State.ONLINE;
|
||||||
|
|
||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ComputerDetails getComputerDetails(boolean likelyOnline) throws IOException, XmlPullParserException {
|
||||||
|
return getComputerDetails(getServerInfo(likelyOnline));
|
||||||
|
}
|
||||||
|
|
||||||
// This hack is Android-specific but we do it on all platforms
|
// This hack is Android-specific but we do it on all platforms
|
||||||
// because it doesn't really matter
|
// because it doesn't really matter
|
||||||
@@ -296,36 +409,27 @@ public class NvHTTP {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public X509Certificate getCertificateIfTrusted() {
|
private HttpUrl getCompleteUrl(HttpUrl baseUrl, String path, String query) {
|
||||||
try {
|
return baseUrl.newBuilder()
|
||||||
Response resp = httpClient.newCall(new Request.Builder().url(baseUrlHttps).get().build()).execute();
|
.addPathSegment(path)
|
||||||
Handshake handshake = resp.handshake();
|
.query(query)
|
||||||
if (handshake != null) {
|
.addQueryParameter("uniqueid", uniqueId)
|
||||||
return (X509Certificate)handshake.peerCertificates().get(0);
|
.addQueryParameter("uuid", UUID.randomUUID().toString())
|
||||||
}
|
.build();
|
||||||
} catch (IOException ignored) {}
|
}
|
||||||
|
|
||||||
return null;
|
private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException {
|
||||||
|
return openHttpConnection(client, baseUrl, path, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read timeout should be enabled for any HTTP query that requires no outside action
|
// Read timeout should be enabled for any HTTP query that requires no outside action
|
||||||
// on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit.
|
// on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit.
|
||||||
// The initial pair query does require outside action (user entering a PIN) but subsequent pairing
|
// The initial pair query does require outside action (user entering a PIN) but subsequent pairing
|
||||||
// queries do not.
|
// queries do not.
|
||||||
private ResponseBody openHttpConnection(String url, boolean enableReadTimeout) throws IOException {
|
private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException {
|
||||||
Request request = new Request.Builder().url(url).get().build();
|
HttpUrl completeUrl = getCompleteUrl(baseUrl, path, query);
|
||||||
Response response;
|
Request request = new Request.Builder().url(completeUrl).get().build();
|
||||||
|
Response response = performAndroidTlsHack(client).newCall(request).execute();
|
||||||
if (serverCert == null && !url.startsWith(baseUrlHttp)) {
|
|
||||||
throw new IllegalStateException("Attempted HTTPS fetch without pinned cert");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableReadTimeout) {
|
|
||||||
response = performAndroidTlsHack(httpClientWithReadTimeout).newCall(request).execute();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
response = performAndroidTlsHack(httpClient).newCall(request).execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
ResponseBody body = response.body();
|
ResponseBody body = response.body();
|
||||||
|
|
||||||
@@ -339,30 +443,31 @@ public class NvHTTP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.code() == 404) {
|
if (response.code() == 404) {
|
||||||
throw new FileNotFoundException(url);
|
throw new FileNotFoundException(completeUrl.toString());
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
throw new GfeHttpResponseException(response.code(), response.message());
|
throw new HostHttpResponseException(response.code(), response.message());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String openHttpConnectionToString(String url, boolean enableReadTimeout) throws IOException {
|
|
||||||
try {
|
|
||||||
if (verbose) {
|
|
||||||
LimeLog.info("Requesting URL: "+url);
|
|
||||||
}
|
|
||||||
|
|
||||||
ResponseBody resp = openHttpConnection(url, enableReadTimeout);
|
private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException {
|
||||||
|
return openHttpConnectionToString(client, baseUrl, path, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException {
|
||||||
|
try {
|
||||||
|
ResponseBody resp = openHttpConnection(client, baseUrl, path, query);
|
||||||
String respString = resp.string();
|
String respString = resp.string();
|
||||||
resp.close();
|
resp.close();
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose && !path.equals("serverinfo")) {
|
||||||
LimeLog.info(url+" -> "+respString);
|
LimeLog.info(getCompleteUrl(baseUrl, path, query)+" -> "+respString);
|
||||||
}
|
}
|
||||||
|
|
||||||
return respString;
|
return respString;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
if (verbose) {
|
if (verbose && !path.equals("serverinfo")) {
|
||||||
|
LimeLog.warning(getCompleteUrl(baseUrl, path, query)+" -> "+e.getMessage());
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,47 +476,35 @@ public class NvHTTP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String getServerVersion(String serverInfo) throws XmlPullParserException, IOException {
|
public String getServerVersion(String serverInfo) throws XmlPullParserException, IOException {
|
||||||
return getXmlString(serverInfo, "appversion");
|
// appversion is present in all supported GFE versions
|
||||||
|
return getXmlString(serverInfo, "appversion", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public PairingManager.PairState getPairState() throws IOException, XmlPullParserException {
|
public PairingManager.PairState getPairState() throws IOException, XmlPullParserException {
|
||||||
return getPairState(getServerInfo());
|
return getPairState(getServerInfo(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
public PairingManager.PairState getPairState(String serverInfo) throws IOException, XmlPullParserException {
|
public PairingManager.PairState getPairState(String serverInfo) throws IOException, XmlPullParserException {
|
||||||
// If we don't have a server cert, we can't be paired even if the host thinks we are
|
// appversion is present in all supported GFE versions
|
||||||
if (serverCert == null) {
|
return NvHTTP.getXmlString(serverInfo, "PairStatus", true).equals("1") ?
|
||||||
return PairState.NOT_PAIRED;
|
PairState.PAIRED : PairState.NOT_PAIRED;
|
||||||
}
|
|
||||||
|
|
||||||
if (!NvHTTP.getXmlString(serverInfo, "PairStatus").equals("1")) {
|
|
||||||
return PairState.NOT_PAIRED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PairState.PAIRED;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getMaxLumaPixelsH264(String serverInfo) throws XmlPullParserException, IOException {
|
public long getMaxLumaPixelsH264(String serverInfo) throws XmlPullParserException, IOException {
|
||||||
String str = getXmlString(serverInfo, "MaxLumaPixelsH264");
|
// MaxLumaPixelsH264 wasn't present on old GFE versions
|
||||||
|
String str = getXmlString(serverInfo, "MaxLumaPixelsH264", false);
|
||||||
if (str != null) {
|
if (str != null) {
|
||||||
try {
|
return Long.parseLong(str);
|
||||||
return Long.parseLong(str);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getMaxLumaPixelsHEVC(String serverInfo) throws XmlPullParserException, IOException {
|
public long getMaxLumaPixelsHEVC(String serverInfo) throws XmlPullParserException, IOException {
|
||||||
String str = getXmlString(serverInfo, "MaxLumaPixelsHEVC");
|
// MaxLumaPixelsHEVC wasn't present on old GFE versions
|
||||||
|
String str = getXmlString(serverInfo, "MaxLumaPixelsHEVC", false);
|
||||||
if (str != null) {
|
if (str != null) {
|
||||||
try {
|
return Long.parseLong(str);
|
||||||
return Long.parseLong(str);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -426,29 +519,28 @@ public class NvHTTP {
|
|||||||
// Bit 10: HEVC Main10 4:4:4
|
// Bit 10: HEVC Main10 4:4:4
|
||||||
// Bit 11: ???
|
// Bit 11: ???
|
||||||
public long getServerCodecModeSupport(String serverInfo) throws XmlPullParserException, IOException {
|
public long getServerCodecModeSupport(String serverInfo) throws XmlPullParserException, IOException {
|
||||||
String str = getXmlString(serverInfo, "ServerCodecModeSupport");
|
// ServerCodecModeSupport wasn't present on old GFE versions
|
||||||
|
String str = getXmlString(serverInfo, "ServerCodecModeSupport", false);
|
||||||
if (str != null) {
|
if (str != null) {
|
||||||
try {
|
return Long.parseLong(str);
|
||||||
return Long.parseLong(str);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getGpuType(String serverInfo) throws XmlPullParserException, IOException {
|
public String getGpuType(String serverInfo) throws XmlPullParserException, IOException {
|
||||||
return getXmlString(serverInfo, "gputype");
|
// ServerCodecModeSupport wasn't present on old GFE versions
|
||||||
|
return getXmlString(serverInfo, "gputype", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getGfeVersion(String serverInfo) throws XmlPullParserException, IOException {
|
public String getGfeVersion(String serverInfo) throws XmlPullParserException, IOException {
|
||||||
return getXmlString(serverInfo, "GfeVersion");
|
// ServerCodecModeSupport wasn't present on old GFE versions
|
||||||
|
return getXmlString(serverInfo, "GfeVersion", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean supports4K(String serverInfo) throws XmlPullParserException, IOException {
|
public boolean supports4K(String serverInfo) throws XmlPullParserException, IOException {
|
||||||
// Only allow 4K on GFE 3.x
|
// Only allow 4K on GFE 3.x. GfeVersion wasn't present on very old versions of GFE.
|
||||||
String gfeVersionStr = getXmlString(serverInfo, "GfeVersion");
|
String gfeVersionStr = getXmlString(serverInfo, "GfeVersion", false);
|
||||||
if (gfeVersionStr == null || gfeVersionStr.startsWith("2.")) {
|
if (gfeVersionStr == null || gfeVersionStr.startsWith("2.")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -460,16 +552,46 @@ public class NvHTTP {
|
|||||||
// GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer
|
// GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer
|
||||||
// has the semantics that its name would indicate. To contain the effects of this change as much
|
// has the semantics that its name would indicate. To contain the effects of this change as much
|
||||||
// as possible, we'll force the current game to zero if the server isn't in a streaming session.
|
// as possible, we'll force the current game to zero if the server isn't in a streaming session.
|
||||||
String serverState = getXmlString(serverInfo, "state");
|
if (getXmlString(serverInfo, "state", true).endsWith("_SERVER_BUSY")) {
|
||||||
if (serverState != null && serverState.endsWith("_SERVER_BUSY")) {
|
return Integer.parseInt(getXmlString(serverInfo, "currentgame", true));
|
||||||
String game = getXmlString(serverInfo, "currentgame");
|
|
||||||
return Integer.parseInt(game);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getHttpsPort(String serverInfo) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(getXmlString(serverInfo, "HttpsPort", true));
|
||||||
|
} catch (XmlPullParserException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return DEFAULT_HTTPS_PORT;
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return DEFAULT_HTTPS_PORT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getExternalPort(String serverInfo) {
|
||||||
|
// This is an extension which is not present in GFE. It is present for Sunshine to be able
|
||||||
|
// to support dynamic HTTP WAN ports without requiring the user to manually enter the port.
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(getXmlString(serverInfo, "ExternalPort", true));
|
||||||
|
} catch (XmlPullParserException e) {
|
||||||
|
// Expected on non-Sunshine servers
|
||||||
|
return baseUrlHttp.port();
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return baseUrlHttp.port();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an app by ID
|
||||||
|
* @param appId The ID of the app
|
||||||
|
* @see #getAppByName(String) for alternative.
|
||||||
|
* @return app details, or null if no app with that ID exists
|
||||||
|
*/
|
||||||
public NvApp getAppById(int appId) throws IOException, XmlPullParserException {
|
public NvApp getAppById(int appId) throws IOException, XmlPullParserException {
|
||||||
LinkedList<NvApp> appList = getAppList();
|
LinkedList<NvApp> appList = getAppList();
|
||||||
for (NvApp appFromList : appList) {
|
for (NvApp appFromList : appList) {
|
||||||
@@ -479,11 +601,16 @@ public class NvHTTP {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* NOTE: Only use this function if you know what you're doing.
|
/**
|
||||||
* It's totally valid to have two apps named the same thing,
|
* Get an app by name
|
||||||
* or even nothing at all! Look apps up by ID if at all possible
|
* NOTE: It is perfectly valid for multiple apps to have the same name,
|
||||||
* using the above function */
|
* this function will only return the first one it finds.
|
||||||
|
* Consider using getAppById instead.
|
||||||
|
* @param appName The name of the app
|
||||||
|
* @see #getAppById(int) for alternative.
|
||||||
|
* @return app details, or null if no app with that name exists
|
||||||
|
*/
|
||||||
public NvApp getAppByName(String appName) throws IOException, XmlPullParserException {
|
public NvApp getAppByName(String appName) throws IOException, XmlPullParserException {
|
||||||
LinkedList<NvApp> appList = getAppList();
|
LinkedList<NvApp> appList = getAppList();
|
||||||
for (NvApp appFromList : appList) {
|
for (NvApp appFromList : appList) {
|
||||||
@@ -529,11 +656,11 @@ public class NvHTTP {
|
|||||||
case (XmlPullParser.TEXT):
|
case (XmlPullParser.TEXT):
|
||||||
NvApp app = appList.getLast();
|
NvApp app = appList.getLast();
|
||||||
if (currentTag.peek().equals("AppTitle")) {
|
if (currentTag.peek().equals("AppTitle")) {
|
||||||
app.setAppName(xpp.getText().trim());
|
app.setAppName(xpp.getText());
|
||||||
} else if (currentTag.peek().equals("ID")) {
|
} else if (currentTag.peek().equals("ID")) {
|
||||||
app.setAppId(xpp.getText().trim());
|
app.setAppId(xpp.getText());
|
||||||
} else if (currentTag.peek().equals("IsHdrSupported")) {
|
} else if (currentTag.peek().equals("IsHdrSupported")) {
|
||||||
app.setHdrSupported(xpp.getText().trim().equals("1"));
|
app.setHdrSupported(xpp.getText().equals("1"));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -560,64 +687,59 @@ public class NvHTTP {
|
|||||||
return appList;
|
return appList;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getAppListRaw() throws MalformedURLException, IOException {
|
public String getAppListRaw() throws IOException {
|
||||||
return openHttpConnectionToString(baseUrlHttps + "/applist?"+buildUniqueIdUuidString(), true);
|
return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "applist");
|
||||||
}
|
}
|
||||||
|
|
||||||
public LinkedList<NvApp> getAppList() throws GfeHttpResponseException, IOException, XmlPullParserException {
|
public LinkedList<NvApp> getAppList() throws HostHttpResponseException, IOException, XmlPullParserException {
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
// Use the raw function so the app list is printed
|
// Use the raw function so the app list is printed
|
||||||
return getAppListByReader(new StringReader(getAppListRaw()));
|
return getAppListByReader(new StringReader(getAppListRaw()));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ResponseBody resp = openHttpConnection(baseUrlHttps + "/applist?" + buildUniqueIdUuidString(), true);
|
try (final ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "applist")) {
|
||||||
LinkedList<NvApp> appList = getAppListByReader(new InputStreamReader(resp.byteStream()));
|
return getAppListByReader(new InputStreamReader(resp.byteStream()));
|
||||||
resp.close();
|
}
|
||||||
return appList;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String executePairingCommand(String additionalArguments, boolean enableReadTimeout) throws HostHttpResponseException, IOException {
|
||||||
|
return openHttpConnectionToString(enableReadTimeout ? httpClientLongConnectTimeout : httpClientLongConnectNoReadTimeout,
|
||||||
|
baseUrlHttp, "pair", "devicename=roth&updateState=1&" + additionalArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
String executePairingChallenge() throws HostHttpResponseException, IOException {
|
||||||
|
return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true),
|
||||||
|
"pair", "devicename=roth&updateState=1&phrase=pairchallenge");
|
||||||
|
}
|
||||||
|
|
||||||
public void unpair() throws IOException {
|
public void unpair() throws IOException {
|
||||||
openHttpConnectionToString(baseUrlHttp + "/unpair?"+buildUniqueIdUuidString(), true);
|
openHttpConnectionToString(httpClientLongConnectTimeout, baseUrlHttp, "unpair");
|
||||||
}
|
}
|
||||||
|
|
||||||
public InputStream getBoxArt(NvApp app) throws IOException {
|
public InputStream getBoxArt(NvApp app) throws IOException {
|
||||||
ResponseBody resp = openHttpConnection(baseUrlHttps + "/appasset?"+ buildUniqueIdUuidString() +
|
ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "appasset", "appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0");
|
||||||
"&appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0", true);
|
|
||||||
return resp.byteStream();
|
return resp.byteStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException {
|
public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException {
|
||||||
int[] appVersionQuad = getServerAppVersionQuad(serverInfo);
|
return getServerAppVersionQuad(serverInfo)[0];
|
||||||
if (appVersionQuad != null) {
|
|
||||||
return appVersionQuad[0];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public int[] getServerAppVersionQuad(String serverInfo) throws XmlPullParserException, IOException {
|
public int[] getServerAppVersionQuad(String serverInfo) throws XmlPullParserException, IOException {
|
||||||
try {
|
String serverVersion = getServerVersion(serverInfo);
|
||||||
String serverVersion = getServerVersion(serverInfo);
|
if (serverVersion == null) {
|
||||||
if (serverVersion == null) {
|
throw new IllegalArgumentException("Missing server version field");
|
||||||
LimeLog.warning("Missing server version field");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String[] serverVersionSplit = serverVersion.split("\\.");
|
|
||||||
if (serverVersionSplit.length != 4) {
|
|
||||||
LimeLog.warning("Malformed server version field");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
int[] ret = new int[serverVersionSplit.length];
|
|
||||||
for (int i = 0; i < ret.length; i++) {
|
|
||||||
ret[i] = Integer.parseInt(serverVersionSplit[i]);
|
|
||||||
}
|
|
||||||
return ret;
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
String[] serverVersionSplit = serverVersion.split("\\.");
|
||||||
|
if (serverVersionSplit.length != 4) {
|
||||||
|
throw new IllegalArgumentException("Malformed server version field: "+serverVersion);
|
||||||
|
}
|
||||||
|
int[] ret = new int[serverVersionSplit.length];
|
||||||
|
for (int i = 0; i < ret.length; i++) {
|
||||||
|
ret[i] = Integer.parseInt(serverVersionSplit[i]);
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
|
final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
|
||||||
@@ -631,70 +753,65 @@ public class NvHTTP {
|
|||||||
return new String(hexChars);
|
return new String(hexChars);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean launchApp(ConnectionContext context, int appId, boolean enableHdr) throws IOException, XmlPullParserException {
|
public boolean launchApp(ConnectionContext context, String verb, int appId, boolean enableHdr) throws IOException, XmlPullParserException {
|
||||||
// Using an unsupported resolution (not 720p, 1080p, or 4K) causes
|
// Using an FPS value over 60 causes SOPS to default to 720p60,
|
||||||
// GFE to force SOPS to 720p60. This is fine for < 720p resolutions like
|
// so force it to 0 to ensure the correct resolution is set. We
|
||||||
// 360p or 480p, but it is not ideal for 1440p and other resolutions.
|
// used to use 60 here but that locked the frame rate to 60 FPS
|
||||||
// When we detect an unsupported resolution, disable SOPS unless it's under 720p.
|
// on GFE 3.20.3.
|
||||||
// FIXME: Detect support resolutions using the serverinfo response, not a hardcoded list
|
int fps = context.isNvidiaServerSoftware && context.streamConfig.getLaunchRefreshRate() > 60 ?
|
||||||
|
0 : context.streamConfig.getLaunchRefreshRate();
|
||||||
|
|
||||||
boolean enableSops = context.streamConfig.getSops();
|
boolean enableSops = context.streamConfig.getSops();
|
||||||
if (context.negotiatedWidth * context.negotiatedHeight > 1280 * 720 &&
|
if (context.isNvidiaServerSoftware) {
|
||||||
context.negotiatedWidth * context.negotiatedHeight != 1920 * 1080 &&
|
// Using an unsupported resolution (not 720p, 1080p, or 4K) causes
|
||||||
context.negotiatedWidth * context.negotiatedHeight != 3840 * 2160) {
|
// GFE to force SOPS to 720p60. This is fine for < 720p resolutions like
|
||||||
LimeLog.info("Disabling SOPS due to non-standard resolution: "+context.negotiatedWidth+"x"+context.negotiatedHeight);
|
// 360p or 480p, but it is not ideal for 1440p and other resolutions.
|
||||||
enableSops = false;
|
// When we detect an unsupported resolution, disable SOPS unless it's under 720p.
|
||||||
|
// FIXME: Detect support resolutions using the serverinfo response, not a hardcoded list
|
||||||
|
if (context.negotiatedWidth * context.negotiatedHeight > 1280 * 720 &&
|
||||||
|
context.negotiatedWidth * context.negotiatedHeight != 1920 * 1080 &&
|
||||||
|
context.negotiatedWidth * context.negotiatedHeight != 3840 * 2160) {
|
||||||
|
LimeLog.info("Disabling SOPS due to non-standard resolution: "+context.negotiatedWidth+"x"+context.negotiatedHeight);
|
||||||
|
enableSops = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Using SOPS with FPS values over 60 causes GFE to fall back
|
String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), verb,
|
||||||
// to 720p60. On previous GFE versions, we could avoid this by
|
"appid=" + appId +
|
||||||
// forcing the FPS value to 60 when launching the stream, but
|
"&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + fps +
|
||||||
// now on GFE 3.20.3 that seems to trigger some sort of
|
|
||||||
// frame rate limiter that locks the game to 60 FPS.
|
|
||||||
if (context.streamConfig.getLaunchRefreshRate() > 60) {
|
|
||||||
LimeLog.info("Disabling SOPS due to high frame rate: "+context.streamConfig.getLaunchRefreshRate());
|
|
||||||
enableSops = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String xmlStr = openHttpConnectionToString(baseUrlHttps +
|
|
||||||
"/launch?" + buildUniqueIdUuidString() +
|
|
||||||
"&appid=" + appId +
|
|
||||||
"&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + context.streamConfig.getLaunchRefreshRate() +
|
|
||||||
"&additionalStates=1&sops=" + (enableSops ? 1 : 0) +
|
"&additionalStates=1&sops=" + (enableSops ? 1 : 0) +
|
||||||
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
|
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
|
||||||
"&rikeyid="+context.riKeyId +
|
"&rikeyid="+context.riKeyId +
|
||||||
(!enableHdr ? "" : "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0") +
|
(!enableHdr ? "" : "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0") +
|
||||||
"&localAudioPlayMode=" + (context.streamConfig.getPlayLocalAudio() ? 1 : 0) +
|
"&localAudioPlayMode=" + (context.streamConfig.getPlayLocalAudio() ? 1 : 0) +
|
||||||
"&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo() +
|
"&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo() +
|
||||||
(context.streamConfig.getAttachedGamepadMask() != 0 ? "&remoteControllersBitmap=" + context.streamConfig.getAttachedGamepadMask() : "") +
|
"&remoteControllersBitmap=" + context.streamConfig.getAttachedGamepadMask() +
|
||||||
(context.streamConfig.getAttachedGamepadMask() != 0 ? "&gcmap=" + context.streamConfig.getAttachedGamepadMask() : ""),
|
"&gcmap=" + context.streamConfig.getAttachedGamepadMask() +
|
||||||
false);
|
"&gcpersist="+(context.streamConfig.getPersistGamepadsAfterDisconnect() ? 1 : 0) +
|
||||||
String gameSession = getXmlString(xmlStr, "gamesession");
|
MoonBridge.getLaunchUrlQueryParameters());
|
||||||
return gameSession != null && !gameSession.equals("0");
|
if ((verb.equals("launch") && !getXmlString(xmlStr, "gamesession", true).equals("0") ||
|
||||||
}
|
(verb.equals("resume") && !getXmlString(xmlStr, "resume", true).equals("0")))) {
|
||||||
|
// sessionUrl0 will be missing for older GFE versions
|
||||||
public boolean resumeApp(ConnectionContext context) throws IOException, XmlPullParserException {
|
context.rtspSessionUrl = getXmlString(xmlStr, "sessionUrl0", false);
|
||||||
String xmlStr = openHttpConnectionToString(baseUrlHttps + "/resume?" + buildUniqueIdUuidString() +
|
return true;
|
||||||
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
|
}
|
||||||
"&rikeyid="+context.riKeyId +
|
else {
|
||||||
"&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo(),
|
return false;
|
||||||
false);
|
}
|
||||||
String resume = getXmlString(xmlStr, "resume");
|
|
||||||
return Integer.parseInt(resume) != 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean quitApp() throws IOException, XmlPullParserException {
|
public boolean quitApp() throws IOException, XmlPullParserException {
|
||||||
String xmlStr = openHttpConnectionToString(baseUrlHttps + "/cancel?" + buildUniqueIdUuidString(), false);
|
String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), "cancel");
|
||||||
String cancel = getXmlString(xmlStr, "cancel");
|
if (getXmlString(xmlStr, "cancel", true).equals("0")) {
|
||||||
if (Integer.parseInt(cancel) == 0) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Newer GFE versions will just return success even if quitting fails
|
// Newer GFE versions will just return success even if quitting fails
|
||||||
// if we're not the original requestor.
|
// if we're not the original requestor.
|
||||||
if (getCurrentGame(getServerInfo()) != 0) {
|
if (getCurrentGame(getServerInfo(true)) != 0) {
|
||||||
// Generate a synthetic GfeResponseException letting the caller know
|
// Generate a synthetic GfeResponseException letting the caller know
|
||||||
// that they can't kill someone else's stream.
|
// that they can't kill someone else's stream.
|
||||||
throw new GfeHttpResponseException(599, "");
|
throw new HostHttpResponseException(599, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.limelight.nvstream.http;
|
package com.limelight.nvstream.http;
|
||||||
|
|
||||||
import javax.crypto.Cipher;
|
import org.bouncycastle.crypto.BlockCipher;
|
||||||
import javax.crypto.SecretKey;
|
import org.bouncycastle.crypto.engines.AESLightEngine;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import org.bouncycastle.crypto.params.KeyParameter;
|
||||||
|
|
||||||
import org.xmlpull.v1.XmlPullParserException;
|
import org.xmlpull.v1.XmlPullParserException;
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ import java.security.*;
|
|||||||
import java.security.cert.*;
|
import java.security.cert.*;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Random;
|
|
||||||
|
|
||||||
public class PairingManager {
|
public class PairingManager {
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ public class PairingManager {
|
|||||||
|
|
||||||
private PrivateKey pk;
|
private PrivateKey pk;
|
||||||
private X509Certificate cert;
|
private X509Certificate cert;
|
||||||
private SecretKey aesKey;
|
|
||||||
private byte[] pemCertBytes;
|
private byte[] pemCertBytes;
|
||||||
|
|
||||||
private X509Certificate serverCert;
|
private X509Certificate serverCert;
|
||||||
@@ -55,6 +53,10 @@ public class PairingManager {
|
|||||||
|
|
||||||
private static byte[] hexToBytes(String s) {
|
private static byte[] hexToBytes(String s) {
|
||||||
int len = s.length();
|
int len = s.length();
|
||||||
|
if (len % 2 != 0) {
|
||||||
|
throw new IllegalArgumentException("Illegal string length: "+len);
|
||||||
|
}
|
||||||
|
|
||||||
byte[] data = new byte[len / 2];
|
byte[] data = new byte[len / 2];
|
||||||
for (int i = 0; i < len; i += 2) {
|
for (int i = 0; i < len; i += 2) {
|
||||||
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
|
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
|
||||||
@@ -65,7 +67,8 @@ public class PairingManager {
|
|||||||
|
|
||||||
private X509Certificate extractPlainCert(String text) throws XmlPullParserException, IOException
|
private X509Certificate extractPlainCert(String text) throws XmlPullParserException, IOException
|
||||||
{
|
{
|
||||||
String certText = NvHTTP.getXmlString(text, "plaincert");
|
// Plaincert may be null if another client is already trying to pair
|
||||||
|
String certText = NvHTTP.getXmlString(text, "plaincert", false);
|
||||||
if (certText != null) {
|
if (certText != null) {
|
||||||
byte[] certBytes = hexToBytes(certText);
|
byte[] certBytes = hexToBytes(certText);
|
||||||
|
|
||||||
@@ -74,7 +77,7 @@ public class PairingManager {
|
|||||||
return (X509Certificate)cf.generateCertificate(new ByteArrayInputStream(certBytes));
|
return (X509Certificate)cf.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||||
} catch (CertificateException e) {
|
} catch (CertificateException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return null;
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -95,10 +98,21 @@ public class PairingManager {
|
|||||||
System.arraycopy(pin.getBytes("UTF-8"), 0, saltedPin, salt.length, pin.length());
|
System.arraycopy(pin.getBytes("UTF-8"), 0, saltedPin, salt.length, pin.length());
|
||||||
return saltedPin;
|
return saltedPin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Signature getSha256SignatureInstanceForKey(Key key) throws NoSuchAlgorithmException {
|
||||||
|
switch (key.getAlgorithm()) {
|
||||||
|
case "RSA":
|
||||||
|
return Signature.getInstance("SHA256withRSA");
|
||||||
|
case "EC":
|
||||||
|
return Signature.getInstance("SHA256withECDSA");
|
||||||
|
default:
|
||||||
|
throw new NoSuchAlgorithmException("Unhandled key algorithm: " + key.getAlgorithm());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean verifySignature(byte[] data, byte[] signature, Certificate cert) {
|
private static boolean verifySignature(byte[] data, byte[] signature, Certificate cert) {
|
||||||
try {
|
try {
|
||||||
Signature sig = Signature.getInstance("SHA256withRSA");
|
Signature sig = PairingManager.getSha256SignatureInstanceForKey(cert.getPublicKey());
|
||||||
sig.initVerify(cert.getPublicKey());
|
sig.initVerify(cert.getPublicKey());
|
||||||
sig.update(data);
|
sig.update(data);
|
||||||
return sig.verify(signature);
|
return sig.verify(signature);
|
||||||
@@ -110,54 +124,44 @@ public class PairingManager {
|
|||||||
|
|
||||||
private static byte[] signData(byte[] data, PrivateKey key) {
|
private static byte[] signData(byte[] data, PrivateKey key) {
|
||||||
try {
|
try {
|
||||||
Signature sig = Signature.getInstance("SHA256withRSA");
|
Signature sig = PairingManager.getSha256SignatureInstanceForKey(key);
|
||||||
sig.initSign(key);
|
sig.initSign(key);
|
||||||
sig.update(data);
|
sig.update(data);
|
||||||
byte[] signature = new byte[256];
|
return sig.sign();
|
||||||
sig.sign(signature, 0, signature.length);
|
|
||||||
return signature;
|
|
||||||
} catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
|
} catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] decryptAes(byte[] encryptedData, SecretKey secretKey) {
|
|
||||||
try {
|
|
||||||
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
|
|
||||||
|
|
||||||
int blockRoundedSize = ((encryptedData.length + 15) / 16) * 16;
|
|
||||||
byte[] blockRoundedEncrypted = Arrays.copyOf(encryptedData, blockRoundedSize);
|
|
||||||
byte[] fullDecrypted = new byte[blockRoundedSize];
|
|
||||||
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, secretKey);
|
private static byte[] performBlockCipher(BlockCipher blockCipher, byte[] input) {
|
||||||
cipher.doFinal(blockRoundedEncrypted, 0,
|
int blockSize = blockCipher.getBlockSize();
|
||||||
blockRoundedSize, fullDecrypted);
|
int blockRoundedSize = (input.length + (blockSize - 1)) & ~(blockSize - 1);
|
||||||
return fullDecrypted;
|
|
||||||
} catch (GeneralSecurityException e) {
|
byte[] blockRoundedInputData = Arrays.copyOf(input, blockRoundedSize);
|
||||||
e.printStackTrace();
|
byte[] blockRoundedOutputData = new byte[blockRoundedSize];
|
||||||
throw new RuntimeException(e);
|
|
||||||
|
for (int offset = 0; offset < blockRoundedSize; offset += blockSize) {
|
||||||
|
blockCipher.processBlock(blockRoundedInputData, offset, blockRoundedOutputData, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return blockRoundedOutputData;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] encryptAes(byte[] data, SecretKey secretKey) {
|
private static byte[] decryptAes(byte[] encryptedData, byte[] aesKey) {
|
||||||
try {
|
BlockCipher aesEngine = new AESLightEngine();
|
||||||
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
|
aesEngine.init(false, new KeyParameter(aesKey));
|
||||||
|
return performBlockCipher(aesEngine, encryptedData);
|
||||||
int blockRoundedSize = ((data.length + 15) / 16) * 16;
|
|
||||||
byte[] blockRoundedData = Arrays.copyOf(data, blockRoundedSize);
|
|
||||||
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
|
||||||
return cipher.doFinal(blockRoundedData);
|
|
||||||
} catch (GeneralSecurityException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SecretKey generateAesKey(PairingHashAlgorithm hashAlgo, byte[] keyData) {
|
private static byte[] encryptAes(byte[] plaintextData, byte[] aesKey) {
|
||||||
byte[] aesTruncated = Arrays.copyOf(hashAlgo.hashData(keyData), 16);
|
BlockCipher aesEngine = new AESLightEngine();
|
||||||
return new SecretKeySpec(aesTruncated, "AES");
|
aesEngine.init(true, new KeyParameter(aesKey));
|
||||||
|
return performBlockCipher(aesEngine, plaintextData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] generateAesKey(PairingHashAlgorithm hashAlgo, byte[] keyData) {
|
||||||
|
return Arrays.copyOf(hashAlgo.hashData(keyData), 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] concatBytes(byte[] a, byte[] b) {
|
private static byte[] concatBytes(byte[] a, byte[] b) {
|
||||||
@@ -168,7 +172,7 @@ public class PairingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String generatePinString() {
|
public static String generatePinString() {
|
||||||
Random r = new Random();
|
SecureRandom r = new SecureRandom();
|
||||||
return String.format((Locale)null, "%d%d%d%d",
|
return String.format((Locale)null, "%d%d%d%d",
|
||||||
r.nextInt(10), r.nextInt(10),
|
r.nextInt(10), r.nextInt(10),
|
||||||
r.nextInt(10), r.nextInt(10));
|
r.nextInt(10), r.nextInt(10));
|
||||||
@@ -196,16 +200,14 @@ public class PairingManager {
|
|||||||
byte[] salt = generateRandomBytes(16);
|
byte[] salt = generateRandomBytes(16);
|
||||||
|
|
||||||
// Combine the salt and pin, then create an AES key from them
|
// Combine the salt and pin, then create an AES key from them
|
||||||
byte[] saltAndPin = saltPin(salt, pin);
|
byte[] aesKey = generateAesKey(hashAlgo, saltPin(salt, pin));
|
||||||
aesKey = generateAesKey(hashAlgo, saltAndPin);
|
|
||||||
|
|
||||||
// Send the salt and get the server cert. This doesn't have a read timeout
|
// Send the salt and get the server cert. This doesn't have a read timeout
|
||||||
// because the user must enter the PIN before the server responds
|
// because the user must enter the PIN before the server responds
|
||||||
String getCert = http.openHttpConnectionToString(http.baseUrlHttp +
|
String getCert = http.executePairingCommand("phrase=getservercert&salt="+
|
||||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&phrase=getservercert&salt="+
|
|
||||||
bytesToHex(salt)+"&clientcert="+bytesToHex(pemCertBytes),
|
bytesToHex(salt)+"&clientcert="+bytesToHex(pemCertBytes),
|
||||||
false);
|
false);
|
||||||
if (!NvHTTP.getXmlString(getCert, "paired").equals("1")) {
|
if (!NvHTTP.getXmlString(getCert, "paired", true).equals("1")) {
|
||||||
return PairState.FAILED;
|
return PairState.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +216,7 @@ public class PairingManager {
|
|||||||
if (serverCert == null) {
|
if (serverCert == null) {
|
||||||
// Attempting to pair while another device is pairing will cause GFE
|
// Attempting to pair while another device is pairing will cause GFE
|
||||||
// to give an empty cert in the response.
|
// to give an empty cert in the response.
|
||||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
http.unpair();
|
||||||
return PairState.ALREADY_IN_PROGRESS;
|
return PairState.ALREADY_IN_PROGRESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,16 +228,14 @@ public class PairingManager {
|
|||||||
byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey);
|
byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey);
|
||||||
|
|
||||||
// Send the encrypted challenge to the server
|
// Send the encrypted challenge to the server
|
||||||
String challengeResp = http.openHttpConnectionToString(http.baseUrlHttp +
|
String challengeResp = http.executePairingCommand("clientchallenge="+bytesToHex(encryptedChallenge), true);
|
||||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&clientchallenge="+bytesToHex(encryptedChallenge),
|
if (!NvHTTP.getXmlString(challengeResp, "paired", true).equals("1")) {
|
||||||
true);
|
http.unpair();
|
||||||
if (!NvHTTP.getXmlString(challengeResp, "paired").equals("1")) {
|
|
||||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
|
||||||
return PairState.FAILED;
|
return PairState.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode the server's response and subsequent challenge
|
// Decode the server's response and subsequent challenge
|
||||||
byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse"));
|
byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse", true));
|
||||||
byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey);
|
byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey);
|
||||||
|
|
||||||
byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength());
|
byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength());
|
||||||
@@ -245,23 +245,21 @@ public class PairingManager {
|
|||||||
byte[] clientSecret = generateRandomBytes(16);
|
byte[] clientSecret = generateRandomBytes(16);
|
||||||
byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret));
|
byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret));
|
||||||
byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey);
|
byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey);
|
||||||
String secretResp = http.openHttpConnectionToString(http.baseUrlHttp +
|
String secretResp = http.executePairingCommand("serverchallengeresp="+bytesToHex(challengeRespEncrypted), true);
|
||||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&serverchallengeresp="+bytesToHex(challengeRespEncrypted),
|
if (!NvHTTP.getXmlString(secretResp, "paired", true).equals("1")) {
|
||||||
true);
|
http.unpair();
|
||||||
if (!NvHTTP.getXmlString(secretResp, "paired").equals("1")) {
|
|
||||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
|
||||||
return PairState.FAILED;
|
return PairState.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the server's signed secret
|
// Get the server's signed secret
|
||||||
byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret"));
|
byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret", true));
|
||||||
byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16);
|
byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16);
|
||||||
byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, 272);
|
byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, serverSecretResp.length);
|
||||||
|
|
||||||
// Ensure the authenticity of the data
|
// Ensure the authenticity of the data
|
||||||
if (!verifySignature(serverSecret, serverSignature, serverCert)) {
|
if (!verifySignature(serverSecret, serverSignature, serverCert)) {
|
||||||
// Cancel the pairing process
|
// Cancel the pairing process
|
||||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
http.unpair();
|
||||||
|
|
||||||
// Looks like a MITM
|
// Looks like a MITM
|
||||||
return PairState.FAILED;
|
return PairState.FAILED;
|
||||||
@@ -271,7 +269,7 @@ public class PairingManager {
|
|||||||
byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret));
|
byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret));
|
||||||
if (!Arrays.equals(serverChallengeRespHash, serverResponse)) {
|
if (!Arrays.equals(serverChallengeRespHash, serverResponse)) {
|
||||||
// Cancel the pairing process
|
// Cancel the pairing process
|
||||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
http.unpair();
|
||||||
|
|
||||||
// Probably got the wrong PIN
|
// Probably got the wrong PIN
|
||||||
return PairState.PIN_WRONG;
|
return PairState.PIN_WRONG;
|
||||||
@@ -279,19 +277,16 @@ public class PairingManager {
|
|||||||
|
|
||||||
// Send the server our signed secret
|
// Send the server our signed secret
|
||||||
byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk));
|
byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk));
|
||||||
String clientSecretResp = http.openHttpConnectionToString(http.baseUrlHttp +
|
String clientSecretResp = http.executePairingCommand("clientpairingsecret="+bytesToHex(clientPairingSecret), true);
|
||||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&clientpairingsecret="+bytesToHex(clientPairingSecret),
|
if (!NvHTTP.getXmlString(clientSecretResp, "paired", true).equals("1")) {
|
||||||
true);
|
http.unpair();
|
||||||
if (!NvHTTP.getXmlString(clientSecretResp, "paired").equals("1")) {
|
|
||||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
|
||||||
return PairState.FAILED;
|
return PairState.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do the initial challenge (seems neccessary for us to show as paired)
|
// Do the initial challenge (seems necessary for us to show as paired)
|
||||||
String pairChallenge = http.openHttpConnectionToString(http.baseUrlHttps +
|
String pairChallenge = http.executePairingChallenge();
|
||||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&phrase=pairchallenge", true);
|
if (!NvHTTP.getXmlString(pairChallenge, "paired", true).equals("1")) {
|
||||||
if (!NvHTTP.getXmlString(pairChallenge, "paired").equals("1")) {
|
http.unpair();
|
||||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
|
||||||
return PairState.FAILED;
|
return PairState.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,9 +309,8 @@ public class PairingManager {
|
|||||||
return md.digest(data);
|
return md.digest(data);
|
||||||
}
|
}
|
||||||
catch (NoSuchAlgorithmException e) {
|
catch (NoSuchAlgorithmException e) {
|
||||||
// Shouldn't ever happen
|
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return null;
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,9 +326,8 @@ public class PairingManager {
|
|||||||
return md.digest(data);
|
return md.digest(data);
|
||||||
}
|
}
|
||||||
catch (NoSuchAlgorithmException e) {
|
catch (NoSuchAlgorithmException e) {
|
||||||
// Shouldn't ever happen
|
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return null;
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
package com.limelight.nvstream.input;
|
package com.limelight.nvstream.input;
|
||||||
|
|
||||||
public class ControllerPacket {
|
public class ControllerPacket {
|
||||||
public static final short A_FLAG = 0x1000;
|
public static final int A_FLAG = 0x1000;
|
||||||
public static final short B_FLAG = 0x2000;
|
public static final int B_FLAG = 0x2000;
|
||||||
public static final short X_FLAG = 0x4000;
|
public static final int X_FLAG = 0x4000;
|
||||||
public static final short Y_FLAG = (short)0x8000;
|
public static final int Y_FLAG = 0x8000;
|
||||||
public static final short UP_FLAG = 0x0001;
|
public static final int UP_FLAG = 0x0001;
|
||||||
public static final short DOWN_FLAG = 0x0002;
|
public static final int DOWN_FLAG = 0x0002;
|
||||||
public static final short LEFT_FLAG = 0x0004;
|
public static final int LEFT_FLAG = 0x0004;
|
||||||
public static final short RIGHT_FLAG = 0x0008;
|
public static final int RIGHT_FLAG = 0x0008;
|
||||||
public static final short LB_FLAG = 0x0100;
|
public static final int LB_FLAG = 0x0100;
|
||||||
public static final short RB_FLAG = 0x0200;
|
public static final int RB_FLAG = 0x0200;
|
||||||
public static final short PLAY_FLAG = 0x0010;
|
public static final int PLAY_FLAG = 0x0010;
|
||||||
public static final short BACK_FLAG = 0x0020;
|
public static final int BACK_FLAG = 0x0020;
|
||||||
public static final short LS_CLK_FLAG = 0x0040;
|
public static final int LS_CLK_FLAG = 0x0040;
|
||||||
public static final short RS_CLK_FLAG = 0x0080;
|
public static final int RS_CLK_FLAG = 0x0080;
|
||||||
public static final short SPECIAL_BUTTON_FLAG = 0x0400;
|
public static final int SPECIAL_BUTTON_FLAG = 0x0400;
|
||||||
|
|
||||||
|
// Extended buttons (Sunshine only)
|
||||||
|
public static final int PADDLE1_FLAG = 0x010000;
|
||||||
|
public static final int PADDLE2_FLAG = 0x020000;
|
||||||
|
public static final int PADDLE3_FLAG = 0x040000;
|
||||||
|
public static final int PADDLE4_FLAG = 0x080000;
|
||||||
|
public static final int TOUCHPAD_FLAG = 0x100000; // Touchpad buttons on Sony controllers
|
||||||
|
public static final int MISC_FLAG = 0x200000; // Share/Mic/Capture/Mute buttons on various controllers
|
||||||
}
|
}
|
||||||
@@ -7,4 +7,5 @@ public class KeyboardPacket {
|
|||||||
public static final byte MODIFIER_SHIFT = 0x01;
|
public static final byte MODIFIER_SHIFT = 0x01;
|
||||||
public static final byte MODIFIER_CTRL = 0x02;
|
public static final byte MODIFIER_CTRL = 0x02;
|
||||||
public static final byte MODIFIER_ALT = 0x04;
|
public static final byte MODIFIER_ALT = 0x04;
|
||||||
}
|
public static final byte MODIFIER_META = 0x08;
|
||||||
|
}
|
||||||
@@ -14,18 +14,33 @@ public class MoonBridge {
|
|||||||
public static final int VIDEO_FORMAT_H264 = 0x0001;
|
public static final int VIDEO_FORMAT_H264 = 0x0001;
|
||||||
public static final int VIDEO_FORMAT_H265 = 0x0100;
|
public static final int VIDEO_FORMAT_H265 = 0x0100;
|
||||||
public static final int VIDEO_FORMAT_H265_MAIN10 = 0x0200;
|
public static final int VIDEO_FORMAT_H265_MAIN10 = 0x0200;
|
||||||
|
public static final int VIDEO_FORMAT_AV1_MAIN8 = 0x1000;
|
||||||
|
public static final int VIDEO_FORMAT_AV1_MAIN10 = 0x2000;
|
||||||
|
|
||||||
public static final int VIDEO_FORMAT_MASK_H264 = 0x00FF;
|
public static final int VIDEO_FORMAT_MASK_H264 = 0x000F;
|
||||||
public static final int VIDEO_FORMAT_MASK_H265 = 0xFF00;
|
public static final int VIDEO_FORMAT_MASK_H265 = 0x0F00;
|
||||||
|
public static final int VIDEO_FORMAT_MASK_AV1 = 0xF000;
|
||||||
|
public static final int VIDEO_FORMAT_MASK_10BIT = 0x2200;
|
||||||
|
|
||||||
public static final int BUFFER_TYPE_PICDATA = 0;
|
public static final int BUFFER_TYPE_PICDATA = 0;
|
||||||
public static final int BUFFER_TYPE_SPS = 1;
|
public static final int BUFFER_TYPE_SPS = 1;
|
||||||
public static final int BUFFER_TYPE_PPS = 2;
|
public static final int BUFFER_TYPE_PPS = 2;
|
||||||
public static final int BUFFER_TYPE_VPS = 3;
|
public static final int BUFFER_TYPE_VPS = 3;
|
||||||
|
|
||||||
|
public static final int FRAME_TYPE_PFRAME = 0;
|
||||||
|
public static final int FRAME_TYPE_IDR = 1;
|
||||||
|
|
||||||
|
public static final int COLORSPACE_REC_601 = 0;
|
||||||
|
public static final int COLORSPACE_REC_709 = 1;
|
||||||
|
public static final int COLORSPACE_REC_2020 = 2;
|
||||||
|
|
||||||
|
public static final int COLOR_RANGE_LIMITED = 0;
|
||||||
|
public static final int COLOR_RANGE_FULL = 1;
|
||||||
|
|
||||||
public static final int CAPABILITY_DIRECT_SUBMIT = 1;
|
public static final int CAPABILITY_DIRECT_SUBMIT = 1;
|
||||||
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC = 2;
|
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC = 2;
|
||||||
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC = 4;
|
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC = 4;
|
||||||
|
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1 = 0x40;
|
||||||
|
|
||||||
public static final int DR_OK = 0;
|
public static final int DR_OK = 0;
|
||||||
public static final int DR_NEED_IDR = -1;
|
public static final int DR_NEED_IDR = -1;
|
||||||
@@ -35,6 +50,79 @@ public class MoonBridge {
|
|||||||
|
|
||||||
public static final int ML_ERROR_GRACEFUL_TERMINATION = 0;
|
public static final int ML_ERROR_GRACEFUL_TERMINATION = 0;
|
||||||
public static final int ML_ERROR_NO_VIDEO_TRAFFIC = -100;
|
public static final int ML_ERROR_NO_VIDEO_TRAFFIC = -100;
|
||||||
|
public static final int ML_ERROR_NO_VIDEO_FRAME = -101;
|
||||||
|
public static final int ML_ERROR_UNEXPECTED_EARLY_TERMINATION = -102;
|
||||||
|
public static final int ML_ERROR_PROTECTED_CONTENT = -103;
|
||||||
|
public static final int ML_ERROR_FRAME_CONVERSION = -104;
|
||||||
|
|
||||||
|
public static final int ML_PORT_INDEX_TCP_47984 = 0;
|
||||||
|
public static final int ML_PORT_INDEX_TCP_47989 = 1;
|
||||||
|
public static final int ML_PORT_INDEX_TCP_48010 = 2;
|
||||||
|
public static final int ML_PORT_INDEX_UDP_47998 = 8;
|
||||||
|
public static final int ML_PORT_INDEX_UDP_47999 = 9;
|
||||||
|
public static final int ML_PORT_INDEX_UDP_48000 = 10;
|
||||||
|
public static final int ML_PORT_INDEX_UDP_48010 = 11;
|
||||||
|
|
||||||
|
public static final int ML_PORT_FLAG_ALL = 0xFFFFFFFF;
|
||||||
|
public static final int ML_PORT_FLAG_TCP_47984 = 0x0001;
|
||||||
|
public static final int ML_PORT_FLAG_TCP_47989 = 0x0002;
|
||||||
|
public static final int ML_PORT_FLAG_TCP_48010 = 0x0004;
|
||||||
|
public static final int ML_PORT_FLAG_UDP_47998 = 0x0100;
|
||||||
|
public static final int ML_PORT_FLAG_UDP_47999 = 0x0200;
|
||||||
|
public static final int ML_PORT_FLAG_UDP_48000 = 0x0400;
|
||||||
|
public static final int ML_PORT_FLAG_UDP_48010 = 0x0800;
|
||||||
|
|
||||||
|
public static final int ML_TEST_RESULT_INCONCLUSIVE = 0xFFFFFFFF;
|
||||||
|
|
||||||
|
public static final byte SS_KBE_FLAG_NON_NORMALIZED = 0x01;
|
||||||
|
|
||||||
|
public static final int LI_ERR_UNSUPPORTED = -5501;
|
||||||
|
|
||||||
|
public static final byte LI_TOUCH_EVENT_HOVER = 0x00;
|
||||||
|
public static final byte LI_TOUCH_EVENT_DOWN = 0x01;
|
||||||
|
public static final byte LI_TOUCH_EVENT_UP = 0x02;
|
||||||
|
public static final byte LI_TOUCH_EVENT_MOVE = 0x03;
|
||||||
|
public static final byte LI_TOUCH_EVENT_CANCEL = 0x04;
|
||||||
|
public static final byte LI_TOUCH_EVENT_BUTTON_ONLY = 0x05;
|
||||||
|
public static final byte LI_TOUCH_EVENT_HOVER_LEAVE = 0x06;
|
||||||
|
public static final byte LI_TOUCH_EVENT_CANCEL_ALL = 0x07;
|
||||||
|
|
||||||
|
public static final byte LI_TOOL_TYPE_UNKNOWN = 0x00;
|
||||||
|
public static final byte LI_TOOL_TYPE_PEN = 0x01;
|
||||||
|
public static final byte LI_TOOL_TYPE_ERASER = 0x02;
|
||||||
|
|
||||||
|
public static final byte LI_PEN_BUTTON_PRIMARY = 0x01;
|
||||||
|
public static final byte LI_PEN_BUTTON_SECONDARY = 0x02;
|
||||||
|
public static final byte LI_PEN_BUTTON_TERTIARY = 0x04;
|
||||||
|
|
||||||
|
public static final byte LI_TILT_UNKNOWN = (byte)0xFF;
|
||||||
|
public static final short LI_ROT_UNKNOWN = (short)0xFFFF;
|
||||||
|
|
||||||
|
public static final byte LI_CTYPE_UNKNOWN = 0x00;
|
||||||
|
public static final byte LI_CTYPE_XBOX = 0x01;
|
||||||
|
public static final byte LI_CTYPE_PS = 0x02;
|
||||||
|
public static final byte LI_CTYPE_NINTENDO = 0x03;
|
||||||
|
|
||||||
|
public static final short LI_CCAP_ANALOG_TRIGGERS = 0x01;
|
||||||
|
public static final short LI_CCAP_RUMBLE = 0x02;
|
||||||
|
public static final short LI_CCAP_TRIGGER_RUMBLE = 0x04;
|
||||||
|
public static final short LI_CCAP_TOUCHPAD = 0x08;
|
||||||
|
public static final short LI_CCAP_ACCEL = 0x10;
|
||||||
|
public static final short LI_CCAP_GYRO = 0x20;
|
||||||
|
public static final short LI_CCAP_BATTERY_STATE = 0x40;
|
||||||
|
public static final short LI_CCAP_RGB_LED = 0x80;
|
||||||
|
|
||||||
|
public static final byte LI_MOTION_TYPE_ACCEL = 0x01;
|
||||||
|
public static final byte LI_MOTION_TYPE_GYRO = 0x02;
|
||||||
|
|
||||||
|
public static final byte LI_BATTERY_STATE_UNKNOWN = 0x00;
|
||||||
|
public static final byte LI_BATTERY_STATE_NOT_PRESENT = 0x01;
|
||||||
|
public static final byte LI_BATTERY_STATE_DISCHARGING = 0x02;
|
||||||
|
public static final byte LI_BATTERY_STATE_CHARGING = 0x03;
|
||||||
|
public static final byte LI_BATTERY_STATE_NOT_CHARGING = 0x04; // Connected to power but not charging
|
||||||
|
public static final byte LI_BATTERY_STATE_FULL = 0x05;
|
||||||
|
|
||||||
|
public static final byte LI_BATTERY_PERCENTAGE_UNKNOWN = (byte)0xFF;
|
||||||
|
|
||||||
private static AudioRenderer audioRenderer;
|
private static AudioRenderer audioRenderer;
|
||||||
private static VideoDecoderRenderer videoRenderer;
|
private static VideoDecoderRenderer videoRenderer;
|
||||||
@@ -127,12 +215,12 @@ public class MoonBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int bridgeDrSubmitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength,
|
public static int bridgeDrSubmitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
|
||||||
int decodeUnitType,
|
int frameNumber, int frameType, char frameHostProcessingLatency,
|
||||||
int frameNumber, long receiveTimeMs) {
|
long receiveTimeMs, long enqueueTimeMs) {
|
||||||
if (videoRenderer != null) {
|
if (videoRenderer != null) {
|
||||||
return videoRenderer.submitDecodeUnit(decodeUnitData, decodeUnitLength,
|
return videoRenderer.submitDecodeUnit(decodeUnitData, decodeUnitLength,
|
||||||
decodeUnitType, frameNumber, receiveTimeMs);
|
decodeUnitType, frameNumber, frameType, frameHostProcessingLatency, receiveTimeMs, enqueueTimeMs);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return DR_OK;
|
return DR_OK;
|
||||||
@@ -186,7 +274,7 @@ public class MoonBridge {
|
|||||||
|
|
||||||
public static void bridgeClStageFailed(int stage, int errorCode) {
|
public static void bridgeClStageFailed(int stage, int errorCode) {
|
||||||
if (connectionListener != null) {
|
if (connectionListener != null) {
|
||||||
connectionListener.stageFailed(getStageName(stage), errorCode);
|
connectionListener.stageFailed(getStageName(stage), getPortFlagsFromStage(stage), errorCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +302,30 @@ public class MoonBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void bridgeClSetHdrMode(boolean enabled, byte[] hdrMetadata) {
|
||||||
|
if (connectionListener != null) {
|
||||||
|
connectionListener.setHdrMode(enabled, hdrMetadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void bridgeClRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) {
|
||||||
|
if (connectionListener != null) {
|
||||||
|
connectionListener.rumbleTriggers(controllerNumber, leftTrigger, rightTrigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void bridgeClSetMotionEventState(short controllerNumber, byte eventType, short sampleRateHz) {
|
||||||
|
if (connectionListener != null) {
|
||||||
|
connectionListener.setMotionEventState(controllerNumber, eventType, sampleRateHz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void bridgeClSetControllerLED(short controllerNumber, byte r, byte g, byte b) {
|
||||||
|
if (connectionListener != null) {
|
||||||
|
connectionListener.setControllerLED(controllerNumber, r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void setupBridge(VideoDecoderRenderer videoRenderer, AudioRenderer audioRenderer, NvConnectionListener connectionListener) {
|
public static void setupBridge(VideoDecoderRenderer videoRenderer, AudioRenderer audioRenderer, NvConnectionListener connectionListener) {
|
||||||
MoonBridge.videoRenderer = videoRenderer;
|
MoonBridge.videoRenderer = videoRenderer;
|
||||||
MoonBridge.audioRenderer = audioRenderer;
|
MoonBridge.audioRenderer = audioRenderer;
|
||||||
@@ -227,14 +339,14 @@ public class MoonBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static native int startConnection(String address, String appVersion, String gfeVersion,
|
public static native int startConnection(String address, String appVersion, String gfeVersion,
|
||||||
|
String rtspSessionUrl, int serverCodecModeSupport,
|
||||||
int width, int height, int fps,
|
int width, int height, int fps,
|
||||||
int bitrate, int packetSize, int streamingRemotely,
|
int bitrate, int packetSize, int streamingRemotely,
|
||||||
int audioConfiguration, boolean supportsHevc,
|
int audioConfiguration, int supportedVideoFormats,
|
||||||
boolean enableHdr,
|
|
||||||
int hevcBitratePercentageMultiplier,
|
|
||||||
int clientRefreshRateX100,
|
int clientRefreshRateX100,
|
||||||
byte[] riAesKey, byte[] riAesIv,
|
byte[] riAesKey, byte[] riAesIv,
|
||||||
int videoCapabilities);
|
int videoCapabilities,
|
||||||
|
int colorSpace, int colorRange);
|
||||||
|
|
||||||
public static native void stopConnection();
|
public static native void stopConnection();
|
||||||
|
|
||||||
@@ -244,25 +356,39 @@ public class MoonBridge {
|
|||||||
|
|
||||||
public static native void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight);
|
public static native void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight);
|
||||||
|
|
||||||
|
public static native void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight);
|
||||||
|
|
||||||
public static native void sendMouseButton(byte buttonEvent, byte mouseButton);
|
public static native void sendMouseButton(byte buttonEvent, byte mouseButton);
|
||||||
|
|
||||||
public static native void sendMultiControllerInput(short controllerNumber,
|
public static native void sendMultiControllerInput(short controllerNumber,
|
||||||
short activeGamepadMask, short buttonFlags,
|
short activeGamepadMask, int buttonFlags,
|
||||||
byte leftTrigger, byte rightTrigger,
|
byte leftTrigger, byte rightTrigger,
|
||||||
short leftStickX, short leftStickY,
|
short leftStickX, short leftStickY,
|
||||||
short rightStickX, short rightStickY);
|
short rightStickX, short rightStickY);
|
||||||
|
|
||||||
public static native void sendControllerInput(short buttonFlags,
|
public static native int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressure,
|
||||||
byte leftTrigger, byte rightTrigger,
|
float contactAreaMajor, float contactAreaMinor, short rotation);
|
||||||
short leftStickX, short leftStickY,
|
|
||||||
short rightStickX, short rightStickY);
|
|
||||||
|
|
||||||
public static native void sendKeyboardInput(short keyMap, byte keyDirection, byte modifier);
|
public static native int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y,
|
||||||
|
float pressure, float contactAreaMajor, float contactAreaMinor,
|
||||||
|
short rotation, byte tilt);
|
||||||
|
|
||||||
public static native void sendMouseScroll(byte scrollClicks);
|
public static native int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type, int supportedButtonFlags, short capabilities);
|
||||||
|
|
||||||
|
public static native int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId, float x, float y, float pressure);
|
||||||
|
|
||||||
|
public static native int sendControllerMotionEvent(byte controllerNumber, byte motionType, float x, float y, float z);
|
||||||
|
|
||||||
|
public static native int sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage);
|
||||||
|
|
||||||
|
public static native void sendKeyboardInput(short keyMap, byte keyDirection, byte modifier, byte flags);
|
||||||
|
|
||||||
public static native void sendMouseHighResScroll(short scrollAmount);
|
public static native void sendMouseHighResScroll(short scrollAmount);
|
||||||
|
|
||||||
|
public static native void sendMouseHighResHScroll(short scrollAmount);
|
||||||
|
|
||||||
|
public static native void sendUtf8Text(String text);
|
||||||
|
|
||||||
public static native String getStageName(int stage);
|
public static native String getStageName(int stage);
|
||||||
|
|
||||||
public static native String findExternalAddressIP4(String stunHostName, int stunPort);
|
public static native String findExternalAddressIP4(String stunHostName, int stunPort);
|
||||||
@@ -271,5 +397,24 @@ public class MoonBridge {
|
|||||||
|
|
||||||
public static native int getPendingVideoFrames();
|
public static native int getPendingVideoFrames();
|
||||||
|
|
||||||
|
public static native int testClientConnectivity(String testServerHostName, int referencePort, int testFlags);
|
||||||
|
|
||||||
|
public static native int getPortFlagsFromStage(int stage);
|
||||||
|
|
||||||
|
public static native int getPortFlagsFromTerminationErrorCode(int errorCode);
|
||||||
|
|
||||||
|
public static native String stringifyPortFlags(int portFlags, String separator);
|
||||||
|
|
||||||
|
// The RTT is in the top 32 bits, and the RTT variance is in the bottom 32 bits
|
||||||
|
public static native long getEstimatedRttInfo();
|
||||||
|
|
||||||
|
public static native String getLaunchUrlQueryParameters();
|
||||||
|
|
||||||
|
public static native byte guessControllerType(int vendorId, int productId);
|
||||||
|
|
||||||
|
public static native boolean guessControllerHasPaddles(int vendorId, int productId);
|
||||||
|
|
||||||
|
public static native boolean guessControllerHasShareButton(int vendorId, int productId);
|
||||||
|
|
||||||
public static native void init();
|
public static native void init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package com.limelight.nvstream.mdns;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.net.wifi.WifiManager;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UnsupportedEncodingException;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.NetworkInterface;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
|
||||||
|
import javax.jmdns.JmmDNS;
|
||||||
|
import javax.jmdns.NetworkTopologyDiscovery;
|
||||||
|
import javax.jmdns.ServiceEvent;
|
||||||
|
import javax.jmdns.ServiceInfo;
|
||||||
|
import javax.jmdns.ServiceListener;
|
||||||
|
import javax.jmdns.impl.NetworkTopologyDiscoveryImpl;
|
||||||
|
|
||||||
|
import com.limelight.LimeLog;
|
||||||
|
|
||||||
|
public class JmDNSDiscoveryAgent extends MdnsDiscoveryAgent implements ServiceListener {
|
||||||
|
private static final String SERVICE_TYPE = "_nvstream._tcp.local.";
|
||||||
|
private WifiManager.MulticastLock multicastLock;
|
||||||
|
private Thread discoveryThread;
|
||||||
|
private HashSet<String> pendingResolution = new HashSet<>();
|
||||||
|
|
||||||
|
// The resolver factory's instance member has a static lifetime which
|
||||||
|
// means our ref count and listener must be static also.
|
||||||
|
private static int resolverRefCount = 0;
|
||||||
|
private static HashSet<ServiceListener> listeners = new HashSet<>();
|
||||||
|
private static ServiceListener nvstreamListener = new ServiceListener() {
|
||||||
|
@Override
|
||||||
|
public void serviceAdded(ServiceEvent event) {
|
||||||
|
HashSet<ServiceListener> localListeners;
|
||||||
|
|
||||||
|
// Copy the listener set into a new set so we can invoke
|
||||||
|
// the callbacks without holding the listeners monitor the
|
||||||
|
// whole time.
|
||||||
|
synchronized (listeners) {
|
||||||
|
localListeners = new HashSet<ServiceListener>(listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ServiceListener listener : localListeners) {
|
||||||
|
listener.serviceAdded(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void serviceRemoved(ServiceEvent event) {
|
||||||
|
HashSet<ServiceListener> localListeners;
|
||||||
|
|
||||||
|
// Copy the listener set into a new set so we can invoke
|
||||||
|
// the callbacks without holding the listeners monitor the
|
||||||
|
// whole time.
|
||||||
|
synchronized (listeners) {
|
||||||
|
localListeners = new HashSet<ServiceListener>(listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ServiceListener listener : localListeners) {
|
||||||
|
listener.serviceRemoved(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void serviceResolved(ServiceEvent event) {
|
||||||
|
HashSet<ServiceListener> localListeners;
|
||||||
|
|
||||||
|
// Copy the listener set into a new set so we can invoke
|
||||||
|
// the callbacks without holding the listeners monitor the
|
||||||
|
// whole time.
|
||||||
|
synchronized (listeners) {
|
||||||
|
localListeners = new HashSet<ServiceListener>(listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ServiceListener listener : localListeners) {
|
||||||
|
listener.serviceResolved(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static class MyNetworkTopologyDiscovery extends NetworkTopologyDiscoveryImpl {
|
||||||
|
@Override
|
||||||
|
public boolean useInetAddress(NetworkInterface networkInterface, InetAddress interfaceAddress) {
|
||||||
|
// This is an copy of jmDNS's implementation, except we omit the multicast check, since
|
||||||
|
// it seems at least some devices lie about interfaces not supporting multicast when they really do.
|
||||||
|
try {
|
||||||
|
if (!networkInterface.isUp()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (!networkInterface.supportsMulticast()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (networkInterface.isLoopback()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
// Override jmDNS's default topology discovery class with ours
|
||||||
|
NetworkTopologyDiscovery.Factory.setClassDelegate(new NetworkTopologyDiscovery.Factory.ClassDelegate() {
|
||||||
|
@Override
|
||||||
|
public NetworkTopologyDiscovery newNetworkTopologyDiscovery() {
|
||||||
|
return new MyNetworkTopologyDiscovery();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JmmDNS referenceResolver() {
|
||||||
|
synchronized (JmDNSDiscoveryAgent.class) {
|
||||||
|
JmmDNS instance = JmmDNS.Factory.getInstance();
|
||||||
|
if (++resolverRefCount == 1) {
|
||||||
|
// This will cause the listener to be invoked for known hosts immediately.
|
||||||
|
// JmDNS only supports one listener per service, so we have to do this here
|
||||||
|
// with a static listener.
|
||||||
|
instance.addServiceListener(SERVICE_TYPE, nvstreamListener);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void dereferenceResolver() {
|
||||||
|
synchronized (JmDNSDiscoveryAgent.class) {
|
||||||
|
if (--resolverRefCount == 0) {
|
||||||
|
try {
|
||||||
|
JmmDNS.Factory.close();
|
||||||
|
} catch (IOException e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public JmDNSDiscoveryAgent(Context context, MdnsDiscoveryListener listener) {
|
||||||
|
super(listener);
|
||||||
|
|
||||||
|
// Create the multicast lock required to receive mDNS traffic
|
||||||
|
WifiManager wifiMgr = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
|
||||||
|
multicastLock = wifiMgr.createMulticastLock("Limelight mDNS");
|
||||||
|
multicastLock.setReferenceCounted(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleResolvedServiceInfo(ServiceInfo info) {
|
||||||
|
synchronized (pendingResolution) {
|
||||||
|
pendingResolution.remove(info.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
handleServiceInfo(info);
|
||||||
|
} catch (UnsupportedEncodingException e) {
|
||||||
|
// Invalid DNS response
|
||||||
|
LimeLog.info("mDNS: Invalid response for machine: "+info.getName());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleServiceInfo(ServiceInfo info) throws UnsupportedEncodingException {
|
||||||
|
reportNewComputer(info.getName(), info.getPort(), info.getInet4Addresses(), info.getInet6Addresses());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startDiscovery(final int discoveryIntervalMs) {
|
||||||
|
// Kill any existing discovery before starting a new one
|
||||||
|
stopDiscovery();
|
||||||
|
|
||||||
|
// Acquire the multicast lock to start receiving mDNS traffic
|
||||||
|
multicastLock.acquire();
|
||||||
|
|
||||||
|
// Add our listener to the set
|
||||||
|
synchronized (listeners) {
|
||||||
|
listeners.add(JmDNSDiscoveryAgent.this);
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveryThread = new Thread() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// This may result in listener callbacks so we must register
|
||||||
|
// our listener first.
|
||||||
|
JmmDNS resolver = referenceResolver();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!Thread.interrupted()) {
|
||||||
|
// Start an mDNS request
|
||||||
|
resolver.requestServiceInfo(SERVICE_TYPE, null, discoveryIntervalMs);
|
||||||
|
|
||||||
|
// Run service resolution again for pending machines
|
||||||
|
ArrayList<String> pendingNames;
|
||||||
|
synchronized (pendingResolution) {
|
||||||
|
pendingNames = new ArrayList<String>(pendingResolution);
|
||||||
|
}
|
||||||
|
for (String name : pendingNames) {
|
||||||
|
LimeLog.info("mDNS: Retrying service resolution for machine: "+name);
|
||||||
|
ServiceInfo[] infos = resolver.getServiceInfos(SERVICE_TYPE, name, 500);
|
||||||
|
if (infos != null && infos.length != 0) {
|
||||||
|
LimeLog.info("mDNS: Resolved (retry) with "+infos.length+" service entries");
|
||||||
|
for (ServiceInfo svcinfo : infos) {
|
||||||
|
handleResolvedServiceInfo(svcinfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the next polling interval
|
||||||
|
try {
|
||||||
|
Thread.sleep(discoveryIntervalMs);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
// Dereference the resolver
|
||||||
|
dereferenceResolver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
discoveryThread.setName("mDNS Discovery Thread");
|
||||||
|
discoveryThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopDiscovery() {
|
||||||
|
// Release the multicast lock to stop receiving mDNS traffic
|
||||||
|
multicastLock.release();
|
||||||
|
|
||||||
|
// Remove our listener from the set
|
||||||
|
synchronized (listeners) {
|
||||||
|
listeners.remove(JmDNSDiscoveryAgent.this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's already a running thread, interrupt it
|
||||||
|
if (discoveryThread != null) {
|
||||||
|
discoveryThread.interrupt();
|
||||||
|
discoveryThread = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void serviceAdded(ServiceEvent event) {
|
||||||
|
LimeLog.info("mDNS: Machine appeared: "+event.getInfo().getName());
|
||||||
|
|
||||||
|
ServiceInfo info = event.getDNS().getServiceInfo(SERVICE_TYPE, event.getInfo().getName(), 500);
|
||||||
|
if (info == null) {
|
||||||
|
// This machine is pending resolution
|
||||||
|
synchronized (pendingResolution) {
|
||||||
|
pendingResolution.add(event.getInfo().getName());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LimeLog.info("mDNS: Resolved (blocking)");
|
||||||
|
handleResolvedServiceInfo(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void serviceRemoved(ServiceEvent event) {
|
||||||
|
LimeLog.info("mDNS: Machine disappeared: "+event.getInfo().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void serviceResolved(ServiceEvent event) {
|
||||||
|
// We handle this synchronously
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,14 @@ import java.net.InetAddress;
|
|||||||
public class MdnsComputer {
|
public class MdnsComputer {
|
||||||
private InetAddress localAddr;
|
private InetAddress localAddr;
|
||||||
private Inet6Address v6Addr;
|
private Inet6Address v6Addr;
|
||||||
|
private int port;
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
public MdnsComputer(String name, InetAddress localAddress, Inet6Address v6Addr) {
|
public MdnsComputer(String name, InetAddress localAddress, Inet6Address v6Addr, int port) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.localAddr = localAddress;
|
this.localAddr = localAddress;
|
||||||
this.v6Addr = v6Addr;
|
this.v6Addr = v6Addr;
|
||||||
|
this.port = port;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
@@ -26,6 +28,10 @@ public class MdnsComputer {
|
|||||||
return v6Addr;
|
return v6Addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getPort() {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
return name.hashCode();
|
return name.hashCode();
|
||||||
@@ -36,7 +42,7 @@ public class MdnsComputer {
|
|||||||
if (o instanceof MdnsComputer) {
|
if (o instanceof MdnsComputer) {
|
||||||
MdnsComputer other = (MdnsComputer)o;
|
MdnsComputer other = (MdnsComputer)o;
|
||||||
|
|
||||||
if (!other.name.equals(name)) {
|
if (!other.name.equals(name) || other.port != port) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,166 +1,64 @@
|
|||||||
package com.limelight.nvstream.mdns;
|
package com.limelight.nvstream.mdns;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.UnsupportedEncodingException;
|
|
||||||
import java.net.Inet4Address;
|
|
||||||
import java.net.Inet6Address;
|
|
||||||
import java.net.InetAddress;
|
|
||||||
import java.net.NetworkInterface;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import javax.jmdns.JmmDNS;
|
|
||||||
import javax.jmdns.NetworkTopologyDiscovery;
|
|
||||||
import javax.jmdns.ServiceEvent;
|
|
||||||
import javax.jmdns.ServiceInfo;
|
|
||||||
import javax.jmdns.ServiceListener;
|
|
||||||
import javax.jmdns.impl.NetworkTopologyDiscoveryImpl;
|
|
||||||
|
|
||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
|
|
||||||
public class MdnsDiscoveryAgent implements ServiceListener {
|
import java.net.Inet4Address;
|
||||||
public static final String SERVICE_TYPE = "_nvstream._tcp.local.";
|
import java.net.Inet6Address;
|
||||||
|
import java.util.ArrayList;
|
||||||
private MdnsDiscoveryListener listener;
|
import java.util.HashSet;
|
||||||
private Thread discoveryThread;
|
import java.util.List;
|
||||||
private HashMap<InetAddress, MdnsComputer> computers = new HashMap<InetAddress, MdnsComputer>();
|
|
||||||
private HashSet<String> pendingResolution = new HashSet<String>();
|
|
||||||
|
|
||||||
// The resolver factory's instance member has a static lifetime which
|
|
||||||
// means our ref count and listener must be static also.
|
|
||||||
private static int resolverRefCount = 0;
|
|
||||||
private static HashSet<ServiceListener> listeners = new HashSet<ServiceListener>();
|
|
||||||
private static ServiceListener nvstreamListener = new ServiceListener() {
|
|
||||||
@Override
|
|
||||||
public void serviceAdded(ServiceEvent event) {
|
|
||||||
HashSet<ServiceListener> localListeners;
|
|
||||||
|
|
||||||
// Copy the listener set into a new set so we can invoke
|
|
||||||
// the callbacks without holding the listeners monitor the
|
|
||||||
// whole time.
|
|
||||||
synchronized (listeners) {
|
|
||||||
localListeners = new HashSet<ServiceListener>(listeners);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (ServiceListener listener : localListeners) {
|
|
||||||
listener.serviceAdded(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
public abstract class MdnsDiscoveryAgent {
|
||||||
public void serviceRemoved(ServiceEvent event) {
|
protected MdnsDiscoveryListener listener;
|
||||||
HashSet<ServiceListener> localListeners;
|
|
||||||
|
|
||||||
// Copy the listener set into a new set so we can invoke
|
|
||||||
// the callbacks without holding the listeners monitor the
|
|
||||||
// whole time.
|
|
||||||
synchronized (listeners) {
|
|
||||||
localListeners = new HashSet<ServiceListener>(listeners);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (ServiceListener listener : localListeners) {
|
|
||||||
listener.serviceRemoved(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
protected HashSet<MdnsComputer> computers = new HashSet<>();
|
||||||
public void serviceResolved(ServiceEvent event) {
|
|
||||||
HashSet<ServiceListener> localListeners;
|
|
||||||
|
|
||||||
// Copy the listener set into a new set so we can invoke
|
|
||||||
// the callbacks without holding the listeners monitor the
|
|
||||||
// whole time.
|
|
||||||
synchronized (listeners) {
|
|
||||||
localListeners = new HashSet<ServiceListener>(listeners);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (ServiceListener listener : localListeners) {
|
|
||||||
listener.serviceResolved(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public static class MyNetworkTopologyDiscovery extends NetworkTopologyDiscoveryImpl {
|
|
||||||
@Override
|
|
||||||
public boolean useInetAddress(NetworkInterface networkInterface, InetAddress interfaceAddress) {
|
|
||||||
// This is an copy of jmDNS's implementation, except we omit the multicast check, since
|
|
||||||
// it seems at least some devices lie about interfaces not supporting multicast when they really do.
|
|
||||||
try {
|
|
||||||
if (!networkInterface.isUp()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
if (!networkInterface.supportsMulticast()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (networkInterface.isLoopback()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (Exception exception) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static {
|
|
||||||
// Override jmDNS's default topology discovery class with ours
|
|
||||||
NetworkTopologyDiscovery.Factory.setClassDelegate(new NetworkTopologyDiscovery.Factory.ClassDelegate() {
|
|
||||||
@Override
|
|
||||||
public NetworkTopologyDiscovery newNetworkTopologyDiscovery() {
|
|
||||||
return new MyNetworkTopologyDiscovery();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JmmDNS referenceResolver() {
|
|
||||||
synchronized (MdnsDiscoveryAgent.class) {
|
|
||||||
JmmDNS instance = JmmDNS.Factory.getInstance();
|
|
||||||
if (++resolverRefCount == 1) {
|
|
||||||
// This will cause the listener to be invoked for known hosts immediately.
|
|
||||||
// JmDNS only supports one listener per service, so we have to do this here
|
|
||||||
// with a static listener.
|
|
||||||
instance.addServiceListener(SERVICE_TYPE, nvstreamListener);
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void dereferenceResolver() {
|
|
||||||
synchronized (MdnsDiscoveryAgent.class) {
|
|
||||||
if (--resolverRefCount == 0) {
|
|
||||||
try {
|
|
||||||
JmmDNS.Factory.close();
|
|
||||||
} catch (IOException e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public MdnsDiscoveryAgent(MdnsDiscoveryListener listener) {
|
public MdnsDiscoveryAgent(MdnsDiscoveryListener listener) {
|
||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleResolvedServiceInfo(ServiceInfo info) {
|
public abstract void startDiscovery(final int discoveryIntervalMs);
|
||||||
synchronized (pendingResolution) {
|
|
||||||
pendingResolution.remove(info.getName());
|
public abstract void stopDiscovery();
|
||||||
|
|
||||||
|
protected void reportNewComputer(String name, int port, Inet4Address[] v4Addrs, Inet6Address[] v6Addrs) {
|
||||||
|
LimeLog.info("mDNS: "+name+" has "+v4Addrs.length+" IPv4 addresses");
|
||||||
|
LimeLog.info("mDNS: "+name+" has "+v6Addrs.length+" IPv6 addresses");
|
||||||
|
|
||||||
|
Inet6Address v6GlobalAddr = getBestIpv6Address(v6Addrs);
|
||||||
|
|
||||||
|
// Add a computer object for each IPv4 address reported by the PC
|
||||||
|
for (Inet4Address v4Addr : v4Addrs) {
|
||||||
|
synchronized (computers) {
|
||||||
|
MdnsComputer computer = new MdnsComputer(name, v4Addr, v6GlobalAddr, port);
|
||||||
|
if (computers.add(computer)) {
|
||||||
|
// This was a new entry
|
||||||
|
listener.notifyComputerAdded(computer);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// If there were no IPv4 addresses, use IPv6 for registration
|
||||||
handleServiceInfo(info);
|
if (v4Addrs.length == 0) {
|
||||||
} catch (UnsupportedEncodingException e) {
|
Inet6Address v6LocalAddr = getLocalAddress(v6Addrs);
|
||||||
// Invalid DNS response
|
|
||||||
LimeLog.info("mDNS: Invalid response for machine: "+info.getName());
|
if (v6LocalAddr != null || v6GlobalAddr != null) {
|
||||||
return;
|
MdnsComputer computer = new MdnsComputer(name, v6LocalAddr, v6GlobalAddr, port);
|
||||||
|
if (computers.add(computer)) {
|
||||||
|
// This was a new entry
|
||||||
|
listener.notifyComputerAdded(computer);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Inet6Address getLocalAddress(Inet6Address[] addresses) {
|
public List<MdnsComputer> getComputerSet() {
|
||||||
|
synchronized (computers) {
|
||||||
|
return new ArrayList<>(computers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static Inet6Address getLocalAddress(Inet6Address[] addresses) {
|
||||||
for (Inet6Address addr : addresses) {
|
for (Inet6Address addr : addresses) {
|
||||||
if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) {
|
if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) {
|
||||||
return addr;
|
return addr;
|
||||||
@@ -174,7 +72,7 @@ public class MdnsDiscoveryAgent implements ServiceListener {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Inet6Address getLinkLocalAddress(Inet6Address[] addresses) {
|
protected static Inet6Address getLinkLocalAddress(Inet6Address[] addresses) {
|
||||||
for (Inet6Address addr : addresses) {
|
for (Inet6Address addr : addresses) {
|
||||||
if (addr.isLinkLocalAddress()) {
|
if (addr.isLinkLocalAddress()) {
|
||||||
LimeLog.info("Found link-local address: "+addr.getHostAddress());
|
LimeLog.info("Found link-local address: "+addr.getHostAddress());
|
||||||
@@ -185,7 +83,7 @@ public class MdnsDiscoveryAgent implements ServiceListener {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Inet6Address getBestIpv6Address(Inet6Address[] addresses) {
|
protected static Inet6Address getBestIpv6Address(Inet6Address[] addresses) {
|
||||||
// First try to find a link local address, so we can match the interface identifier
|
// First try to find a link local address, so we can match the interface identifier
|
||||||
// with a global address (this will work for SLAAC but not DHCPv6).
|
// with a global address (this will work for SLAAC but not DHCPv6).
|
||||||
Inet6Address linkLocalAddr = getLinkLocalAddress(addresses);
|
Inet6Address linkLocalAddr = getLinkLocalAddress(addresses);
|
||||||
@@ -247,162 +145,4 @@ public class MdnsDiscoveryAgent implements ServiceListener {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleServiceInfo(ServiceInfo info) throws UnsupportedEncodingException {
|
|
||||||
Inet4Address v4Addrs[] = info.getInet4Addresses();
|
|
||||||
Inet6Address v6Addrs[] = info.getInet6Addresses();
|
|
||||||
|
|
||||||
LimeLog.info("mDNS: "+info.getName()+" has "+v4Addrs.length+" IPv4 addresses");
|
|
||||||
LimeLog.info("mDNS: "+info.getName()+" has "+v6Addrs.length+" IPv6 addresses");
|
|
||||||
|
|
||||||
Inet6Address v6GlobalAddr = getBestIpv6Address(v6Addrs);
|
|
||||||
|
|
||||||
// Add a computer object for each IPv4 address reported by the PC
|
|
||||||
for (Inet4Address v4Addr : v4Addrs) {
|
|
||||||
synchronized (computers) {
|
|
||||||
MdnsComputer computer = new MdnsComputer(info.getName(), v4Addr, v6GlobalAddr);
|
|
||||||
if (computers.put(computer.getLocalAddress(), computer) == null) {
|
|
||||||
// This was a new entry
|
|
||||||
listener.notifyComputerAdded(computer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there were no IPv4 addresses, use IPv6 for registration
|
|
||||||
if (v4Addrs.length == 0) {
|
|
||||||
Inet6Address v6LocalAddr = getLocalAddress(v6Addrs);
|
|
||||||
|
|
||||||
if (v6LocalAddr != null || v6GlobalAddr != null) {
|
|
||||||
MdnsComputer computer = new MdnsComputer(info.getName(), v6LocalAddr, v6GlobalAddr);
|
|
||||||
if (computers.put(v6LocalAddr != null ?
|
|
||||||
computer.getLocalAddress() : computer.getIpv6Address(), computer) == null) {
|
|
||||||
// This was a new entry
|
|
||||||
listener.notifyComputerAdded(computer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void startDiscovery(final int discoveryIntervalMs) {
|
|
||||||
// Kill any existing discovery before starting a new one
|
|
||||||
stopDiscovery();
|
|
||||||
|
|
||||||
// Add our listener to the set
|
|
||||||
synchronized (listeners) {
|
|
||||||
listeners.add(MdnsDiscoveryAgent.this);
|
|
||||||
}
|
|
||||||
|
|
||||||
discoveryThread = new Thread() {
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
// This may result in listener callbacks so we must register
|
|
||||||
// our listener first.
|
|
||||||
JmmDNS resolver = referenceResolver();
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!Thread.interrupted()) {
|
|
||||||
// Start an mDNS request
|
|
||||||
resolver.requestServiceInfo(SERVICE_TYPE, null, discoveryIntervalMs);
|
|
||||||
|
|
||||||
// Run service resolution again for pending machines
|
|
||||||
ArrayList<String> pendingNames;
|
|
||||||
synchronized (pendingResolution) {
|
|
||||||
pendingNames = new ArrayList<String>(pendingResolution);
|
|
||||||
}
|
|
||||||
for (String name : pendingNames) {
|
|
||||||
LimeLog.info("mDNS: Retrying service resolution for machine: "+name);
|
|
||||||
ServiceInfo[] infos = resolver.getServiceInfos(SERVICE_TYPE, name, 500);
|
|
||||||
if (infos != null && infos.length != 0) {
|
|
||||||
LimeLog.info("mDNS: Resolved (retry) with "+infos.length+" service entries");
|
|
||||||
for (ServiceInfo svcinfo : infos) {
|
|
||||||
handleResolvedServiceInfo(svcinfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the next polling interval
|
|
||||||
try {
|
|
||||||
Thread.sleep(discoveryIntervalMs);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
// Dereference the resolver
|
|
||||||
dereferenceResolver();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
discoveryThread.setName("mDNS Discovery Thread");
|
|
||||||
discoveryThread.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void stopDiscovery() {
|
|
||||||
// Remove our listener from the set
|
|
||||||
synchronized (listeners) {
|
|
||||||
listeners.remove(MdnsDiscoveryAgent.this);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's already a running thread, interrupt it
|
|
||||||
if (discoveryThread != null) {
|
|
||||||
discoveryThread.interrupt();
|
|
||||||
discoveryThread = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<MdnsComputer> getComputerSet() {
|
|
||||||
synchronized (computers) {
|
|
||||||
return new ArrayList<MdnsComputer>(computers.values());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void serviceAdded(ServiceEvent event) {
|
|
||||||
LimeLog.info("mDNS: Machine appeared: "+event.getInfo().getName());
|
|
||||||
|
|
||||||
ServiceInfo info = event.getDNS().getServiceInfo(SERVICE_TYPE, event.getInfo().getName(), 500);
|
|
||||||
if (info == null) {
|
|
||||||
// This machine is pending resolution
|
|
||||||
synchronized (pendingResolution) {
|
|
||||||
pendingResolution.add(event.getInfo().getName());
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LimeLog.info("mDNS: Resolved (blocking)");
|
|
||||||
handleResolvedServiceInfo(info);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void serviceRemoved(ServiceEvent event) {
|
|
||||||
LimeLog.info("mDNS: Machine disappeared: "+event.getInfo().getName());
|
|
||||||
|
|
||||||
Inet4Address v4Addrs[] = event.getInfo().getInet4Addresses();
|
|
||||||
for (Inet4Address addr : v4Addrs) {
|
|
||||||
synchronized (computers) {
|
|
||||||
MdnsComputer computer = computers.remove(addr);
|
|
||||||
if (computer != null) {
|
|
||||||
listener.notifyComputerRemoved(computer);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Inet6Address v6Addrs[] = event.getInfo().getInet6Addresses();
|
|
||||||
for (Inet6Address addr : v6Addrs) {
|
|
||||||
synchronized (computers) {
|
|
||||||
MdnsComputer computer = computers.remove(addr);
|
|
||||||
if (computer != null) {
|
|
||||||
listener.notifyComputerRemoved(computer);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void serviceResolved(ServiceEvent event) {
|
|
||||||
// We handle this synchronously
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,5 @@ package com.limelight.nvstream.mdns;
|
|||||||
|
|
||||||
public interface MdnsDiscoveryListener {
|
public interface MdnsDiscoveryListener {
|
||||||
void notifyComputerAdded(MdnsComputer computer);
|
void notifyComputerAdded(MdnsComputer computer);
|
||||||
void notifyComputerRemoved(MdnsComputer computer);
|
|
||||||
void notifyDiscoveryFailure(Exception e);
|
void notifyDiscoveryFailure(Exception e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.limelight.nvstream.mdns;
|
||||||
|
|
||||||
|
import android.annotation.TargetApi;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.net.nsd.NsdManager;
|
||||||
|
import android.net.nsd.NsdServiceInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
|
import com.limelight.LimeLog;
|
||||||
|
|
||||||
|
import java.net.Inet4Address;
|
||||||
|
import java.net.Inet6Address;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
public class NsdManagerDiscoveryAgent extends MdnsDiscoveryAgent {
|
||||||
|
private static final String SERVICE_TYPE = "_nvstream._tcp";
|
||||||
|
private final NsdManager nsdManager;
|
||||||
|
private final Object listenerLock = new Object();
|
||||||
|
private NsdManager.DiscoveryListener pendingListener;
|
||||||
|
private NsdManager.DiscoveryListener activeListener;
|
||||||
|
private final HashMap<String, NsdManager.ServiceInfoCallback> serviceCallbacks = new HashMap<>();
|
||||||
|
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
|
||||||
|
|
||||||
|
private NsdManager.DiscoveryListener createDiscoveryListener() {
|
||||||
|
return new NsdManager.DiscoveryListener() {
|
||||||
|
@Override
|
||||||
|
public void onStartDiscoveryFailed(String serviceType, int errorCode) {
|
||||||
|
LimeLog.severe("NSD: Service discovery start failed: " + errorCode);
|
||||||
|
|
||||||
|
// This listener is no longer pending after this failure
|
||||||
|
synchronized (listenerLock) {
|
||||||
|
if (pendingListener != this) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
listener.notifyDiscoveryFailure(new RuntimeException("onStartDiscoveryFailed(): " + errorCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStopDiscoveryFailed(String serviceType, int errorCode) {
|
||||||
|
LimeLog.severe("NSD: Service discovery stop failed: " + errorCode);
|
||||||
|
|
||||||
|
// This listener is no longer active after this failure
|
||||||
|
synchronized (listenerLock) {
|
||||||
|
if (activeListener != this) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeListener = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDiscoveryStarted(String serviceType) {
|
||||||
|
LimeLog.info("NSD: Service discovery started");
|
||||||
|
|
||||||
|
synchronized (listenerLock) {
|
||||||
|
if (pendingListener != this) {
|
||||||
|
// If we registered another discovery listener in the meantime, stop this one
|
||||||
|
nsdManager.stopServiceDiscovery(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingListener = null;
|
||||||
|
activeListener = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDiscoveryStopped(String serviceType) {
|
||||||
|
LimeLog.info("NSD: Service discovery stopped");
|
||||||
|
|
||||||
|
synchronized (listenerLock) {
|
||||||
|
if (activeListener != this) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeListener = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceFound(NsdServiceInfo nsdServiceInfo) {
|
||||||
|
// Protect against racing stopDiscovery() call
|
||||||
|
synchronized (listenerLock) {
|
||||||
|
// Ignore callbacks if we're not the active listener
|
||||||
|
if (activeListener != this) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LimeLog.info("NSD: Machine appeared: " + nsdServiceInfo.getServiceName());
|
||||||
|
|
||||||
|
NsdManager.ServiceInfoCallback serviceInfoCallback = new NsdManager.ServiceInfoCallback() {
|
||||||
|
@Override
|
||||||
|
public void onServiceInfoCallbackRegistrationFailed(int errorCode) {
|
||||||
|
LimeLog.severe("NSD: Service info callback registration failed: " + errorCode);
|
||||||
|
listener.notifyDiscoveryFailure(new RuntimeException("onServiceInfoCallbackRegistrationFailed(): " + errorCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceUpdated(NsdServiceInfo nsdServiceInfo) {
|
||||||
|
LimeLog.info("NSD: Machine resolved: " + nsdServiceInfo.getServiceName());
|
||||||
|
reportNewComputer(nsdServiceInfo.getServiceName(), nsdServiceInfo.getPort(),
|
||||||
|
getV4Addrs(nsdServiceInfo.getHostAddresses()),
|
||||||
|
getV6Addrs(nsdServiceInfo.getHostAddresses()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceLost() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceInfoCallbackUnregistered() {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
nsdManager.registerServiceInfoCallback(nsdServiceInfo, executor, serviceInfoCallback);
|
||||||
|
serviceCallbacks.put(nsdServiceInfo.getServiceName(), serviceInfoCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceLost(NsdServiceInfo nsdServiceInfo) {
|
||||||
|
// Protect against racing stopDiscovery() call
|
||||||
|
synchronized (listenerLock) {
|
||||||
|
// Ignore callbacks if we're not the active listener
|
||||||
|
if (activeListener != this) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LimeLog.info("NSD: Machine lost: " + nsdServiceInfo.getServiceName());
|
||||||
|
|
||||||
|
NsdManager.ServiceInfoCallback serviceInfoCallback = serviceCallbacks.remove(nsdServiceInfo.getServiceName());
|
||||||
|
if (serviceInfoCallback != null) {
|
||||||
|
nsdManager.unregisterServiceInfoCallback(serviceInfoCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public NsdManagerDiscoveryAgent(Context context, MdnsDiscoveryListener listener) {
|
||||||
|
super(listener);
|
||||||
|
this.nsdManager = context.getSystemService(NsdManager.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startDiscovery(int discoveryIntervalMs) {
|
||||||
|
synchronized (listenerLock) {
|
||||||
|
// Register a new service discovery listener if there's not already one starting or running
|
||||||
|
if (pendingListener == null && activeListener == null) {
|
||||||
|
pendingListener = createDiscoveryListener();
|
||||||
|
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, pendingListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stopDiscovery() {
|
||||||
|
// Protect against racing ServiceInfoCallback and DiscoveryListener callbacks
|
||||||
|
synchronized (listenerLock) {
|
||||||
|
// Clear any pending listener to ensure the discoverStarted() callback
|
||||||
|
// will realize it's gone and stop itself.
|
||||||
|
pendingListener = null;
|
||||||
|
|
||||||
|
// Unregister the service discovery listener
|
||||||
|
if (activeListener != null) {
|
||||||
|
nsdManager.stopServiceDiscovery(activeListener);
|
||||||
|
|
||||||
|
// Even though listener stoppage is asynchronous, the listener is gone as far as
|
||||||
|
// we're concerned. We null this right now to ensure pending callbacks know it's
|
||||||
|
// stopped and startDiscovery() can immediately create a new listener. If we left
|
||||||
|
// it until onDiscoveryStopped() was called, startDiscovery() would get confused
|
||||||
|
// and assume a listener was already running, even though it's stopping.
|
||||||
|
activeListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister all service info callbacks
|
||||||
|
for (NsdManager.ServiceInfoCallback callback : serviceCallbacks.values()) {
|
||||||
|
nsdManager.unregisterServiceInfoCallback(callback);
|
||||||
|
}
|
||||||
|
serviceCallbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Inet4Address[] getV4Addrs(List<InetAddress> addrs) {
|
||||||
|
int matchCount = 0;
|
||||||
|
for (InetAddress addr : addrs) {
|
||||||
|
if (addr instanceof Inet4Address) {
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Inet4Address[] matching = new Inet4Address[matchCount];
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
for (InetAddress addr : addrs) {
|
||||||
|
if (addr instanceof Inet4Address) {
|
||||||
|
matching[i++] = (Inet4Address) addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Inet6Address[] getV6Addrs(List<InetAddress> addrs) {
|
||||||
|
int matchCount = 0;
|
||||||
|
for (InetAddress addr : addrs) {
|
||||||
|
if (addr instanceof Inet6Address) {
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Inet6Address[] matching = new Inet6Address[matchCount];
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
for (InetAddress addr : addrs) {
|
||||||
|
if (addr instanceof Inet6Address) {
|
||||||
|
matching[i++] = (Inet6Address) addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,37 +10,87 @@ import com.limelight.LimeLog;
|
|||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
|
|
||||||
public class WakeOnLanSender {
|
public class WakeOnLanSender {
|
||||||
private static final int[] PORTS_TO_TRY = new int[] {
|
// These ports will always be tried as-is.
|
||||||
7, 9, // Standard WOL ports
|
private static final int[] STATIC_PORTS_TO_TRY = new int[] {
|
||||||
47998, 47999, 48000, 48002, 48010 // Ports opened by GFE
|
9, // Standard WOL port (privileged port)
|
||||||
|
47009, // Port opened by Moonlight Internet Hosting Tool for WoL (non-privileged port)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// These ports will be offset by the base port number (47989) to support alternate ports.
|
||||||
|
private static final int[] DYNAMIC_PORTS_TO_TRY = new int[] {
|
||||||
|
47998, 47999, 48000, 48002, 48010, // Ports opened by GFE
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void sendPacketsForAddress(InetAddress address, int httpPort, DatagramSocket sock, byte[] payload) throws IOException {
|
||||||
|
IOException lastException = null;
|
||||||
|
boolean sentWolPacket = false;
|
||||||
|
|
||||||
|
// Try the static ports
|
||||||
|
for (int port : STATIC_PORTS_TO_TRY) {
|
||||||
|
try {
|
||||||
|
DatagramPacket dp = new DatagramPacket(payload, payload.length);
|
||||||
|
dp.setAddress(address);
|
||||||
|
dp.setPort(port);
|
||||||
|
sock.send(dp);
|
||||||
|
sentWolPacket = true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
lastException = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the dynamic ports
|
||||||
|
for (int port : DYNAMIC_PORTS_TO_TRY) {
|
||||||
|
try {
|
||||||
|
DatagramPacket dp = new DatagramPacket(payload, payload.length);
|
||||||
|
dp.setAddress(address);
|
||||||
|
dp.setPort((port - 47989) + httpPort);
|
||||||
|
sock.send(dp);
|
||||||
|
sentWolPacket = true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
lastException = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sentWolPacket) {
|
||||||
|
throw lastException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void sendWolPacket(ComputerDetails computer) throws IOException {
|
public static void sendWolPacket(ComputerDetails computer) throws IOException {
|
||||||
DatagramSocket sock = new DatagramSocket(0);
|
|
||||||
byte[] payload = createWolPayload(computer);
|
byte[] payload = createWolPayload(computer);
|
||||||
IOException lastException = null;
|
IOException lastException = null;
|
||||||
boolean sentWolPacket = false;
|
boolean sentWolPacket = false;
|
||||||
|
|
||||||
try {
|
try (final DatagramSocket sock = new DatagramSocket(0)) {
|
||||||
// Try all resolved remote and local addresses and IPv4 broadcast address.
|
// Try all resolved remote and local addresses and broadcast addresses.
|
||||||
// The broadcast address is required to avoid stale ARP cache entries
|
// The broadcast address is required to avoid stale ARP cache entries
|
||||||
// making the sleeping machine unreachable.
|
// making the sleeping machine unreachable.
|
||||||
for (String unresolvedAddress : new String[] {
|
for (ComputerDetails.AddressTuple address : new ComputerDetails.AddressTuple[] {
|
||||||
computer.localAddress, computer.remoteAddress, computer.manualAddress, computer.ipv6Address, "255.255.255.255"
|
computer.localAddress, computer.remoteAddress,
|
||||||
|
computer.manualAddress, computer.ipv6Address,
|
||||||
}) {
|
}) {
|
||||||
if (unresolvedAddress == null) {
|
if (address == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (InetAddress resolvedAddress : InetAddress.getAllByName(unresolvedAddress)) {
|
sendPacketsForAddress(InetAddress.getByName("255.255.255.255"), address.port, sock, payload);
|
||||||
// Try all the ports for each resolved address
|
sentWolPacket = true;
|
||||||
for (int port : PORTS_TO_TRY) {
|
} catch (IOException e) {
|
||||||
DatagramPacket dp = new DatagramPacket(payload, payload.length);
|
e.printStackTrace();
|
||||||
dp.setAddress(resolvedAddress);
|
lastException = e;
|
||||||
dp.setPort(port);
|
}
|
||||||
sock.send(dp);
|
|
||||||
|
try {
|
||||||
|
for (InetAddress resolvedAddress : InetAddress.getAllByName(address.address)) {
|
||||||
|
try {
|
||||||
|
sendPacketsForAddress(resolvedAddress, address.port, sock, payload);
|
||||||
sentWolPacket = true;
|
sentWolPacket = true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
lastException = e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@@ -51,8 +101,6 @@ public class WakeOnLanSender {
|
|||||||
lastException = e;
|
lastException = e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
sock.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Propagate the DNS resolution exception if we didn't
|
// Propagate the DNS resolution exception if we didn't
|
||||||
@@ -64,18 +112,20 @@ public class WakeOnLanSender {
|
|||||||
|
|
||||||
private static byte[] macStringToBytes(String macAddress) {
|
private static byte[] macStringToBytes(String macAddress) {
|
||||||
byte[] macBytes = new byte[6];
|
byte[] macBytes = new byte[6];
|
||||||
@SuppressWarnings("resource")
|
|
||||||
Scanner scan = new Scanner(macAddress).useDelimiter(":");
|
try (@SuppressWarnings("resource")
|
||||||
for (int i = 0; i < macBytes.length && scan.hasNext(); i++) {
|
final Scanner scan = new Scanner(macAddress).useDelimiter(":")
|
||||||
try {
|
) {
|
||||||
macBytes[i] = (byte) Integer.parseInt(scan.next(), 16);
|
for (int i = 0; i < macBytes.length && scan.hasNext(); i++) {
|
||||||
} catch (NumberFormatException e) {
|
try {
|
||||||
LimeLog.warning("Malformed MAC address: "+macAddress+" (index: "+i+")");
|
macBytes[i] = (byte) Integer.parseInt(scan.next(), 16);
|
||||||
break;
|
} catch (NumberFormatException e) {
|
||||||
|
LimeLog.warning("Malformed MAC address: " + macAddress + " (index: " + i + ")");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return macBytes;
|
||||||
}
|
}
|
||||||
scan.close();
|
|
||||||
return macBytes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] createWolPayload(ComputerDetails computer) {
|
private static byte[] createWolPayload(ComputerDetails computer) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import java.net.InetAddress;
|
|||||||
import java.net.InterfaceAddress;
|
import java.net.InterfaceAddress;
|
||||||
import java.net.NetworkInterface;
|
import java.net.NetworkInterface;
|
||||||
import java.net.SocketException;
|
import java.net.SocketException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
@@ -15,7 +17,9 @@ import com.limelight.computers.ComputerManagerService;
|
|||||||
import com.limelight.R;
|
import com.limelight.R;
|
||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
import com.limelight.nvstream.http.NvHTTP;
|
import com.limelight.nvstream.http.NvHTTP;
|
||||||
|
import com.limelight.nvstream.jni.MoonBridge;
|
||||||
import com.limelight.utils.Dialog;
|
import com.limelight.utils.Dialog;
|
||||||
|
import com.limelight.utils.ServerHelper;
|
||||||
import com.limelight.utils.SpinnerDialog;
|
import com.limelight.utils.SpinnerDialog;
|
||||||
import com.limelight.utils.UiHelper;
|
import com.limelight.utils.UiHelper;
|
||||||
|
|
||||||
@@ -86,48 +90,108 @@ public class AddComputerManually extends Activity {
|
|||||||
|
|
||||||
// Couldn't find a matching interface
|
// Couldn't find a matching interface
|
||||||
return true;
|
return true;
|
||||||
} catch (SocketException e) {
|
} catch (Exception e) {
|
||||||
|
// Catch all exceptions because some broken Android devices
|
||||||
|
// will throw an NPE from inside getNetworkInterfaces().
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return false;
|
return false;
|
||||||
} catch (UnknownHostException e) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void doAddPc(String host) {
|
private URI parseRawUserInputToUri(String rawUserInput) {
|
||||||
|
try {
|
||||||
|
// Try adding a scheme and parsing the remaining input.
|
||||||
|
// This handles input like 127.0.0.1:47989, [::1], [::1]:47989, and 127.0.0.1.
|
||||||
|
URI uri = new URI("moonlight://" + rawUserInput);
|
||||||
|
if (uri.getHost() != null && !uri.getHost().isEmpty()) {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
} catch (URISyntaxException ignored) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt to escape the input as an IPv6 literal.
|
||||||
|
// This handles input like ::1.
|
||||||
|
URI uri = new URI("moonlight://[" + rawUserInput + "]");
|
||||||
|
if (uri.getHost() != null && !uri.getHost().isEmpty()) {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
} catch (URISyntaxException ignored) {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doAddPc(String rawUserInput) throws InterruptedException {
|
||||||
boolean wrongSiteLocal = false;
|
boolean wrongSiteLocal = false;
|
||||||
|
boolean invalidInput = false;
|
||||||
boolean success;
|
boolean success;
|
||||||
|
int portTestResult;
|
||||||
|
|
||||||
SpinnerDialog dialog = SpinnerDialog.displayDialog(this, getResources().getString(R.string.title_add_pc),
|
SpinnerDialog dialog = SpinnerDialog.displayDialog(this, getResources().getString(R.string.title_add_pc),
|
||||||
getResources().getString(R.string.msg_add_pc), false);
|
getResources().getString(R.string.msg_add_pc), false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ComputerDetails details = new ComputerDetails();
|
ComputerDetails details = new ComputerDetails();
|
||||||
details.manualAddress = host;
|
|
||||||
|
|
||||||
try {
|
// Check if we parsed a host address successfully
|
||||||
NvHTTP http = new NvHTTP(host, managerBinder.getUniqueId(), null, PlatformBinding.getCryptoProvider(this));
|
URI uri = parseRawUserInputToUri(rawUserInput);
|
||||||
details.serverCert = http.getCertificateIfTrusted();
|
if (uri != null && uri.getHost() != null && !uri.getHost().isEmpty()) {
|
||||||
} catch (IOException ignored) {}
|
String host = uri.getHost();
|
||||||
|
int port = uri.getPort();
|
||||||
|
|
||||||
success = managerBinder.addComputerBlocking(details);
|
// If a port was not specified, use the default
|
||||||
|
if (port == -1) {
|
||||||
|
port = NvHTTP.DEFAULT_HTTP_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.manualAddress = new ComputerDetails.AddressTuple(host, port);
|
||||||
|
success = managerBinder.addComputerBlocking(details);
|
||||||
|
if (!success){
|
||||||
|
wrongSiteLocal = isWrongSubnetSiteLocalAddress(host);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Invalid user input
|
||||||
|
success = false;
|
||||||
|
invalidInput = true;
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
// Propagate the InterruptedException to the caller for proper handling
|
||||||
|
dialog.dismiss();
|
||||||
|
throw e;
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
// This can be thrown from OkHttp if the host fails to canonicalize to a valid name.
|
// This can be thrown from OkHttp if the host fails to canonicalize to a valid name.
|
||||||
// https://github.com/square/okhttp/blob/okhttp_27/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java#L705
|
// https://github.com/square/okhttp/blob/okhttp_27/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java#L705
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
success = false;
|
success = false;
|
||||||
|
invalidInput = true;
|
||||||
}
|
}
|
||||||
if (!success){
|
|
||||||
wrongSiteLocal = isWrongSubnetSiteLocalAddress(host);
|
// Keep the SpinnerDialog open while testing connectivity
|
||||||
|
if (!success && !wrongSiteLocal && !invalidInput) {
|
||||||
|
// Run the test before dismissing the spinner because it can take a few seconds.
|
||||||
|
portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443,
|
||||||
|
MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989);
|
||||||
|
} else {
|
||||||
|
// Don't bother with the test if we succeeded or the IP address was bogus
|
||||||
|
portTestResult = MoonBridge.ML_TEST_RESULT_INCONCLUSIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.dismiss();
|
dialog.dismiss();
|
||||||
|
|
||||||
if (wrongSiteLocal) {
|
if (invalidInput) {
|
||||||
|
Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_unknown_host), false);
|
||||||
|
}
|
||||||
|
else if (wrongSiteLocal) {
|
||||||
Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_wrong_sitelocal), false);
|
Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_wrong_sitelocal), false);
|
||||||
}
|
}
|
||||||
else if (!success) {
|
else if (!success) {
|
||||||
Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_fail), false);
|
String dialogText;
|
||||||
|
if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) {
|
||||||
|
dialogText = getResources().getString(R.string.nettest_text_blocked);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
dialogText = getResources().getString(R.string.addpc_fail);
|
||||||
|
}
|
||||||
|
Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), dialogText, false);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
AddComputerManually.this.runOnUiThread(new Runnable() {
|
AddComputerManually.this.runOnUiThread(new Runnable() {
|
||||||
@@ -150,15 +214,12 @@ public class AddComputerManually extends Activity {
|
|||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
while (!isInterrupted()) {
|
while (!isInterrupted()) {
|
||||||
String computer;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
computer = computersToAdd.take();
|
String computer = computersToAdd.take();
|
||||||
|
doAddPc(computer);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
doAddPc(computer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -172,7 +233,14 @@ public class AddComputerManually extends Activity {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
addThread.join();
|
addThread.join();
|
||||||
} catch (InterruptedException ignored) {}
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
|
||||||
|
// InterruptedException clears the thread's interrupt status. Since we can't
|
||||||
|
// handle that here, we will re-interrupt the thread to set the interrupt
|
||||||
|
// status back to true.
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
|
||||||
addThread = null;
|
addThread = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import com.limelight.R;
|
|||||||
import static com.limelight.binding.input.virtual_controller.VirtualControllerConfigurationLoader.OSC_PREFERENCE;
|
import static com.limelight.binding.input.virtual_controller.VirtualControllerConfigurationLoader.OSC_PREFERENCE;
|
||||||
|
|
||||||
public class ConfirmDeleteOscPreference extends DialogPreference {
|
public class ConfirmDeleteOscPreference extends DialogPreference {
|
||||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
|
||||||
public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||||
super(context, attrs, defStyleAttr, defStyleRes);
|
super(context, attrs, defStyleAttr, defStyleRes);
|
||||||
}
|
}
|
||||||
@@ -26,7 +25,6 @@ public class ConfirmDeleteOscPreference extends DialogPreference {
|
|||||||
super(context, attrs);
|
super(context, attrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
|
||||||
public ConfirmDeleteOscPreference(Context context) {
|
public ConfirmDeleteOscPreference(Context context) {
|
||||||
super(context);
|
super(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.limelight.preferences;
|
||||||
|
|
||||||
|
import android.annotation.TargetApi;
|
||||||
|
import android.content.ActivityNotFoundException;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.preference.ListPreference;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
|
||||||
|
public class LanguagePreference extends ListPreference {
|
||||||
|
public LanguagePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||||
|
super(context, attrs, defStyleAttr, defStyleRes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LanguagePreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LanguagePreference(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LanguagePreference(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onClick() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
try {
|
||||||
|
// Launch the Android native app locale settings page
|
||||||
|
Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS);
|
||||||
|
intent.addCategory(Intent.CATEGORY_DEFAULT);
|
||||||
|
intent.setData(Uri.parse("package:" + getContext().getPackageName()));
|
||||||
|
getContext().startActivity(intent, null);
|
||||||
|
return;
|
||||||
|
} catch (ActivityNotFoundException e) {
|
||||||
|
// App locale settings should be present on all Android 13 devices,
|
||||||
|
// but if not, we'll launch the old language chooser.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't have native app locale settings, launch the normal dialog
|
||||||
|
super.onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,24 @@ import android.content.SharedPreferences;
|
|||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.preference.PreferenceManager;
|
import android.preference.PreferenceManager;
|
||||||
|
import android.view.Display;
|
||||||
|
|
||||||
import com.limelight.nvstream.jni.MoonBridge;
|
import com.limelight.nvstream.jni.MoonBridge;
|
||||||
|
|
||||||
public class PreferenceConfiguration {
|
public class PreferenceConfiguration {
|
||||||
|
public enum FormatOption {
|
||||||
|
AUTO,
|
||||||
|
FORCE_AV1,
|
||||||
|
FORCE_HEVC,
|
||||||
|
FORCE_H264,
|
||||||
|
};
|
||||||
|
|
||||||
|
public enum AnalogStickForScrolling {
|
||||||
|
NONE,
|
||||||
|
RIGHT,
|
||||||
|
LEFT
|
||||||
|
}
|
||||||
|
|
||||||
private static final String LEGACY_RES_FPS_PREF_STRING = "list_resolution_fps";
|
private static final String LEGACY_RES_FPS_PREF_STRING = "list_resolution_fps";
|
||||||
private static final String LEGACY_ENABLE_51_SURROUND_PREF_STRING = "checkbox_51_surround";
|
private static final String LEGACY_ENABLE_51_SURROUND_PREF_STRING = "checkbox_51_surround";
|
||||||
|
|
||||||
@@ -23,7 +37,6 @@ public class PreferenceConfiguration {
|
|||||||
private static final String DEADZONE_PREF_STRING = "seekbar_deadzone";
|
private static final String DEADZONE_PREF_STRING = "seekbar_deadzone";
|
||||||
private static final String OSC_OPACITY_PREF_STRING = "seekbar_osc_opacity";
|
private static final String OSC_OPACITY_PREF_STRING = "seekbar_osc_opacity";
|
||||||
private static final String LANGUAGE_PREF_STRING = "list_languages";
|
private static final String LANGUAGE_PREF_STRING = "list_languages";
|
||||||
private static final String LIST_MODE_PREF_STRING = "checkbox_list_mode";
|
|
||||||
private static final String SMALL_ICONS_PREF_STRING = "checkbox_small_icon_mode";
|
private static final String SMALL_ICONS_PREF_STRING = "checkbox_small_icon_mode";
|
||||||
private static final String MULTI_CONTROLLER_PREF_STRING = "checkbox_multi_controller";
|
private static final String MULTI_CONTROLLER_PREF_STRING = "checkbox_multi_controller";
|
||||||
static final String AUDIO_CONFIG_PREF_STRING = "list_audio_config";
|
static final String AUDIO_CONFIG_PREF_STRING = "list_audio_config";
|
||||||
@@ -31,127 +44,215 @@ public class PreferenceConfiguration {
|
|||||||
private static final String VIDEO_FORMAT_PREF_STRING = "video_format";
|
private static final String VIDEO_FORMAT_PREF_STRING = "video_format";
|
||||||
private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls";
|
private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls";
|
||||||
private static final String ONLY_L3_R3_PREF_STRING = "checkbox_only_show_L3R3";
|
private static final String ONLY_L3_R3_PREF_STRING = "checkbox_only_show_L3R3";
|
||||||
private static final String DISABLE_FRAME_DROP_PREF_STRING = "checkbox_disable_frame_drop";
|
private static final String SHOW_GUIDE_BUTTON_PREF_STRING = "checkbox_show_guide_button";
|
||||||
|
private static final String LEGACY_DISABLE_FRAME_DROP_PREF_STRING = "checkbox_disable_frame_drop";
|
||||||
private static final String ENABLE_HDR_PREF_STRING = "checkbox_enable_hdr";
|
private static final String ENABLE_HDR_PREF_STRING = "checkbox_enable_hdr";
|
||||||
private static final String ENABLE_PIP_PREF_STRING = "checkbox_enable_pip";
|
private static final String ENABLE_PIP_PREF_STRING = "checkbox_enable_pip";
|
||||||
private static final String ENABLE_PERF_OVERLAY_STRING = "checkbox_enable_perf_overlay";
|
private static final String ENABLE_PERF_OVERLAY_STRING = "checkbox_enable_perf_overlay";
|
||||||
private static final String BIND_ALL_USB_STRING = "checkbox_usb_bind_all";
|
private static final String BIND_ALL_USB_STRING = "checkbox_usb_bind_all";
|
||||||
private static final String MOUSE_EMULATION_STRING = "checkbox_mouse_emulation";
|
private static final String MOUSE_EMULATION_STRING = "checkbox_mouse_emulation";
|
||||||
|
private static final String ANALOG_SCROLLING_PREF_STRING = "analog_scrolling";
|
||||||
private static final String MOUSE_NAV_BUTTONS_STRING = "checkbox_mouse_nav_buttons";
|
private static final String MOUSE_NAV_BUTTONS_STRING = "checkbox_mouse_nav_buttons";
|
||||||
static final String UNLOCK_FPS_STRING = "checkbox_unlock_fps";
|
static final String UNLOCK_FPS_STRING = "checkbox_unlock_fps";
|
||||||
private static final String VIBRATE_OSC_PREF_STRING = "checkbox_vibrate_osc";
|
private static final String VIBRATE_OSC_PREF_STRING = "checkbox_vibrate_osc";
|
||||||
private static final String VIBRATE_FALLBACK_PREF_STRING = "checkbox_vibrate_fallback";
|
private static final String VIBRATE_FALLBACK_PREF_STRING = "checkbox_vibrate_fallback";
|
||||||
|
private static final String VIBRATE_FALLBACK_STRENGTH_PREF_STRING = "seekbar_vibrate_fallback_strength";
|
||||||
private static final String FLIP_FACE_BUTTONS_PREF_STRING = "checkbox_flip_face_buttons";
|
private static final String FLIP_FACE_BUTTONS_PREF_STRING = "checkbox_flip_face_buttons";
|
||||||
private static final String TOUCHSCREEN_TRACKPAD_PREF_STRING = "checkbox_touchscreen_trackpad";
|
private static final String TOUCHSCREEN_TRACKPAD_PREF_STRING = "checkbox_touchscreen_trackpad";
|
||||||
|
private static final String LATENCY_TOAST_PREF_STRING = "checkbox_enable_post_stream_toast";
|
||||||
|
private static final String FRAME_PACING_PREF_STRING = "frame_pacing";
|
||||||
|
private static final String ABSOLUTE_MOUSE_MODE_PREF_STRING = "checkbox_absolute_mouse_mode";
|
||||||
|
private static final String ENABLE_AUDIO_FX_PREF_STRING = "checkbox_enable_audiofx";
|
||||||
|
private static final String REDUCE_REFRESH_RATE_PREF_STRING = "checkbox_reduce_refresh_rate";
|
||||||
|
private static final String FULL_RANGE_PREF_STRING = "checkbox_full_range";
|
||||||
|
private static final String GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING = "checkbox_gamepad_touchpad_as_mouse";
|
||||||
|
private static final String GAMEPAD_MOTION_SENSORS_PREF_STRING = "checkbox_gamepad_motion_sensors";
|
||||||
|
private static final String GAMEPAD_MOTION_FALLBACK_PREF_STRING = "checkbox_gamepad_motion_fallback";
|
||||||
|
|
||||||
static final String DEFAULT_RESOLUTION = "720p";
|
static final String DEFAULT_RESOLUTION = "1280x720";
|
||||||
static final String DEFAULT_FPS = "60";
|
static final String DEFAULT_FPS = "60";
|
||||||
private static final boolean DEFAULT_STRETCH = false;
|
private static final boolean DEFAULT_STRETCH = false;
|
||||||
private static final boolean DEFAULT_SOPS = true;
|
private static final boolean DEFAULT_SOPS = true;
|
||||||
private static final boolean DEFAULT_DISABLE_TOASTS = false;
|
private static final boolean DEFAULT_DISABLE_TOASTS = false;
|
||||||
private static final boolean DEFAULT_HOST_AUDIO = false;
|
private static final boolean DEFAULT_HOST_AUDIO = false;
|
||||||
private static final int DEFAULT_DEADZONE = 15;
|
private static final int DEFAULT_DEADZONE = 7;
|
||||||
private static final int DEFAULT_OPACITY = 90;
|
private static final int DEFAULT_OPACITY = 90;
|
||||||
public static final String DEFAULT_LANGUAGE = "default";
|
public static final String DEFAULT_LANGUAGE = "default";
|
||||||
private static final boolean DEFAULT_LIST_MODE = false;
|
|
||||||
private static final boolean DEFAULT_MULTI_CONTROLLER = true;
|
private static final boolean DEFAULT_MULTI_CONTROLLER = true;
|
||||||
private static final boolean DEFAULT_USB_DRIVER = true;
|
private static final boolean DEFAULT_USB_DRIVER = true;
|
||||||
private static final String DEFAULT_VIDEO_FORMAT = "auto";
|
private static final String DEFAULT_VIDEO_FORMAT = "auto";
|
||||||
|
|
||||||
private static final boolean ONSCREEN_CONTROLLER_DEFAULT = false;
|
private static final boolean ONSCREEN_CONTROLLER_DEFAULT = false;
|
||||||
private static final boolean ONLY_L3_R3_DEFAULT = false;
|
private static final boolean ONLY_L3_R3_DEFAULT = false;
|
||||||
private static final boolean DEFAULT_DISABLE_FRAME_DROP = false;
|
private static final boolean SHOW_GUIDE_BUTTON_DEFAULT = true;
|
||||||
private static final boolean DEFAULT_ENABLE_HDR = false;
|
private static final boolean DEFAULT_ENABLE_HDR = false;
|
||||||
private static final boolean DEFAULT_ENABLE_PIP = false;
|
private static final boolean DEFAULT_ENABLE_PIP = false;
|
||||||
private static final boolean DEFAULT_ENABLE_PERF_OVERLAY = false;
|
private static final boolean DEFAULT_ENABLE_PERF_OVERLAY = false;
|
||||||
private static final boolean DEFAULT_BIND_ALL_USB = false;
|
private static final boolean DEFAULT_BIND_ALL_USB = false;
|
||||||
private static final boolean DEFAULT_MOUSE_EMULATION = true;
|
private static final boolean DEFAULT_MOUSE_EMULATION = true;
|
||||||
|
private static final String DEFAULT_ANALOG_STICK_FOR_SCROLLING = "right";
|
||||||
private static final boolean DEFAULT_MOUSE_NAV_BUTTONS = false;
|
private static final boolean DEFAULT_MOUSE_NAV_BUTTONS = false;
|
||||||
private static final boolean DEFAULT_UNLOCK_FPS = false;
|
private static final boolean DEFAULT_UNLOCK_FPS = false;
|
||||||
private static final boolean DEFAULT_VIBRATE_OSC = true;
|
private static final boolean DEFAULT_VIBRATE_OSC = true;
|
||||||
private static final boolean DEFAULT_VIBRATE_FALLBACK = false;
|
private static final boolean DEFAULT_VIBRATE_FALLBACK = false;
|
||||||
|
private static final int DEFAULT_VIBRATE_FALLBACK_STRENGTH = 100;
|
||||||
private static final boolean DEFAULT_FLIP_FACE_BUTTONS = false;
|
private static final boolean DEFAULT_FLIP_FACE_BUTTONS = false;
|
||||||
private static final boolean DEFAULT_TOUCHSCREEN_TRACKPAD = true;
|
private static final boolean DEFAULT_TOUCHSCREEN_TRACKPAD = true;
|
||||||
private static final String DEFAULT_AUDIO_CONFIG = "2"; // Stereo
|
private static final String DEFAULT_AUDIO_CONFIG = "2"; // Stereo
|
||||||
|
private static final boolean DEFAULT_LATENCY_TOAST = false;
|
||||||
|
private static final String DEFAULT_FRAME_PACING = "latency";
|
||||||
|
private static final boolean DEFAULT_ABSOLUTE_MOUSE_MODE = false;
|
||||||
|
private static final boolean DEFAULT_ENABLE_AUDIO_FX = false;
|
||||||
|
private static final boolean DEFAULT_REDUCE_REFRESH_RATE = false;
|
||||||
|
private static final boolean DEFAULT_FULL_RANGE = false;
|
||||||
|
private static final boolean DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE = false;
|
||||||
|
private static final boolean DEFAULT_GAMEPAD_MOTION_SENSORS = true;
|
||||||
|
private static final boolean DEFAULT_GAMEPAD_MOTION_FALLBACK = false;
|
||||||
|
|
||||||
public static final int FORCE_H265_ON = -1;
|
public static final int FRAME_PACING_MIN_LATENCY = 0;
|
||||||
public static final int AUTOSELECT_H265 = 0;
|
public static final int FRAME_PACING_BALANCED = 1;
|
||||||
public static final int FORCE_H265_OFF = 1;
|
public static final int FRAME_PACING_CAP_FPS = 2;
|
||||||
|
public static final int FRAME_PACING_MAX_SMOOTHNESS = 3;
|
||||||
|
|
||||||
|
public static final String RES_360P = "640x360";
|
||||||
|
public static final String RES_480P = "854x480";
|
||||||
|
public static final String RES_720P = "1280x720";
|
||||||
|
public static final String RES_1080P = "1920x1080";
|
||||||
|
public static final String RES_1440P = "2560x1440";
|
||||||
|
public static final String RES_4K = "3840x2160";
|
||||||
|
public static final String RES_NATIVE = "Native";
|
||||||
|
|
||||||
public int width, height, fps;
|
public int width, height, fps;
|
||||||
public int bitrate;
|
public int bitrate;
|
||||||
public int videoFormat;
|
public FormatOption videoFormat;
|
||||||
public int deadzonePercentage;
|
public int deadzonePercentage;
|
||||||
public int oscOpacity;
|
public int oscOpacity;
|
||||||
public boolean stretchVideo, enableSops, playHostAudio, disableWarnings;
|
public boolean stretchVideo, enableSops, playHostAudio, disableWarnings;
|
||||||
public String language;
|
public String language;
|
||||||
public boolean listMode, smallIconMode, multiController, usbDriver, flipFaceButtons;
|
public boolean smallIconMode, multiController, usbDriver, flipFaceButtons;
|
||||||
public boolean onscreenController;
|
public boolean onscreenController;
|
||||||
public boolean onlyL3R3;
|
public boolean onlyL3R3;
|
||||||
public boolean disableFrameDrop;
|
public boolean showGuideButton;
|
||||||
public boolean enableHdr;
|
public boolean enableHdr;
|
||||||
public boolean enablePip;
|
public boolean enablePip;
|
||||||
public boolean enablePerfOverlay;
|
public boolean enablePerfOverlay;
|
||||||
|
public boolean enableLatencyToast;
|
||||||
public boolean bindAllUsb;
|
public boolean bindAllUsb;
|
||||||
public boolean mouseEmulation;
|
public boolean mouseEmulation;
|
||||||
|
public AnalogStickForScrolling analogStickForScrolling;
|
||||||
public boolean mouseNavButtons;
|
public boolean mouseNavButtons;
|
||||||
public boolean unlockFps;
|
public boolean unlockFps;
|
||||||
public boolean vibrateOsc;
|
public boolean vibrateOsc;
|
||||||
public boolean vibrateFallbackToDevice;
|
public boolean vibrateFallbackToDevice;
|
||||||
|
public int vibrateFallbackToDeviceStrength;
|
||||||
public boolean touchscreenTrackpad;
|
public boolean touchscreenTrackpad;
|
||||||
public MoonBridge.AudioConfiguration audioConfiguration;
|
public MoonBridge.AudioConfiguration audioConfiguration;
|
||||||
|
public int framePacing;
|
||||||
|
public boolean absoluteMouseMode;
|
||||||
|
public boolean enableAudioFx;
|
||||||
|
public boolean reduceRefreshRate;
|
||||||
|
public boolean fullRange;
|
||||||
|
public boolean gamepadMotionSensors;
|
||||||
|
public boolean gamepadTouchpadAsMouse;
|
||||||
|
public boolean gamepadMotionSensorsFallbackToDevice;
|
||||||
|
|
||||||
private static int getHeightFromResolutionString(String resString) {
|
public static boolean isNativeResolution(int width, int height) {
|
||||||
|
// It's not a native resolution if it matches an existing resolution option
|
||||||
|
if (width == 640 && height == 360) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (width == 854 && height == 480) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (width == 1280 && height == 720) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (width == 1920 && height == 1080) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (width == 2560 && height == 1440) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (width == 3840 && height == 2160) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a screen that has semi-square dimensions, we may want to change our behavior
|
||||||
|
// to allow any orientation and vertical+horizontal resolutions.
|
||||||
|
public static boolean isSquarishScreen(int width, int height) {
|
||||||
|
float longDim = Math.max(width, height);
|
||||||
|
float shortDim = Math.min(width, height);
|
||||||
|
|
||||||
|
// We just put the arbitrary cutoff for a square-ish screen at 1.3
|
||||||
|
return longDim / shortDim < 1.3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isSquarishScreen(Display display) {
|
||||||
|
int width, height;
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
width = display.getMode().getPhysicalWidth();
|
||||||
|
height = display.getMode().getPhysicalHeight();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
width = display.getWidth();
|
||||||
|
height = display.getHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSquarishScreen(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String convertFromLegacyResolutionString(String resString) {
|
||||||
if (resString.equalsIgnoreCase("360p")) {
|
if (resString.equalsIgnoreCase("360p")) {
|
||||||
return 360;
|
return RES_360P;
|
||||||
}
|
}
|
||||||
else if (resString.equalsIgnoreCase("480p")) {
|
else if (resString.equalsIgnoreCase("480p")) {
|
||||||
return 480;
|
return RES_480P;
|
||||||
}
|
}
|
||||||
else if (resString.equalsIgnoreCase("720p")) {
|
else if (resString.equalsIgnoreCase("720p")) {
|
||||||
return 720;
|
return RES_720P;
|
||||||
}
|
}
|
||||||
else if (resString.equalsIgnoreCase("1080p")) {
|
else if (resString.equalsIgnoreCase("1080p")) {
|
||||||
return 1080;
|
return RES_1080P;
|
||||||
}
|
}
|
||||||
else if (resString.equalsIgnoreCase("1440p")) {
|
else if (resString.equalsIgnoreCase("1440p")) {
|
||||||
return 1440;
|
return RES_1440P;
|
||||||
}
|
}
|
||||||
else if (resString.equalsIgnoreCase("4K")) {
|
else if (resString.equalsIgnoreCase("4K")) {
|
||||||
return 2160;
|
return RES_4K;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Should be unreachable
|
// Should be unreachable
|
||||||
return 720;
|
return RES_720P;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int getWidthFromResolutionString(String resString) {
|
private static int getWidthFromResolutionString(String resString) {
|
||||||
int height = getHeightFromResolutionString(resString);
|
return Integer.parseInt(resString.split("x")[0]);
|
||||||
if (height == 480) {
|
}
|
||||||
// This isn't an exact 16:9 resolution
|
|
||||||
return 854;
|
private static int getHeightFromResolutionString(String resString) {
|
||||||
}
|
return Integer.parseInt(resString.split("x")[1]);
|
||||||
else {
|
|
||||||
return (height * 16) / 9;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String getResolutionString(int width, int height) {
|
private static String getResolutionString(int width, int height) {
|
||||||
switch (height) {
|
switch (height) {
|
||||||
case 360:
|
case 360:
|
||||||
return "360p";
|
return RES_360P;
|
||||||
case 480:
|
case 480:
|
||||||
return "480p";
|
return RES_480P;
|
||||||
default:
|
default:
|
||||||
case 720:
|
case 720:
|
||||||
return "720p";
|
return RES_720P;
|
||||||
case 1080:
|
case 1080:
|
||||||
return "1080p";
|
return RES_1080P;
|
||||||
case 1440:
|
case 1440:
|
||||||
return "1440p";
|
return RES_1440P;
|
||||||
case 2160:
|
case 2160:
|
||||||
return "4K";
|
return RES_4K;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,33 +261,62 @@ public class PreferenceConfiguration {
|
|||||||
int height = getHeightFromResolutionString(resString);
|
int height = getHeightFromResolutionString(resString);
|
||||||
int fps = Integer.parseInt(fpsString);
|
int fps = Integer.parseInt(fpsString);
|
||||||
|
|
||||||
// This table prefers 16:10 resolutions because they are
|
|
||||||
// only slightly more pixels than the 16:9 equivalents, so
|
|
||||||
// we don't want to bump those 16:10 resolutions up to the
|
|
||||||
// next 16:9 slot.
|
|
||||||
//
|
|
||||||
// This logic is shamelessly stolen from Moonlight Qt:
|
// This logic is shamelessly stolen from Moonlight Qt:
|
||||||
// https://github.com/moonlight-stream/moonlight-qt/blob/master/app/settings/streamingpreferences.cpp
|
// https://github.com/moonlight-stream/moonlight-qt/blob/master/app/settings/streamingpreferences.cpp
|
||||||
|
|
||||||
if (width * height <= 640 * 360) {
|
// Don't scale bitrate linearly beyond 60 FPS. It's definitely not a linear
|
||||||
return (int)(1000 * (fps / 30.0));
|
// bitrate increase for frame rate once we get to values that high.
|
||||||
}
|
double frameRateFactor = (fps <= 60 ? fps : (Math.sqrt(fps / 60.f) * 60.f)) / 30.f;
|
||||||
else if (width * height <= 854 * 480) {
|
|
||||||
return (int)(1500 * (fps / 30.0));
|
// TODO: Collect some empirical data to see if these defaults make sense.
|
||||||
}
|
// We're just using the values that the Shield used, as we have for years.
|
||||||
// This covers 1280x720 and 1280x800 too
|
int[] pixelVals = {
|
||||||
else if (width * height <= 1366 * 768) {
|
640 * 360,
|
||||||
return (int)(5000 * (fps / 30.0));
|
854 * 480,
|
||||||
}
|
1280 * 720,
|
||||||
else if (width * height <= 1920 * 1200) {
|
1920 * 1080,
|
||||||
return (int)(10000 * (fps / 30.0));
|
2560 * 1440,
|
||||||
}
|
3840 * 2160,
|
||||||
else if (width * height <= 2560 * 1600) {
|
-1,
|
||||||
return (int)(20000 * (fps / 30.0));
|
};
|
||||||
}
|
int[] factorVals = {
|
||||||
else /* if (width * height <= 3840 * 2160) */ {
|
1,
|
||||||
return (int)(40000 * (fps / 30.0));
|
2,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
40,
|
||||||
|
-1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate the resolution factor by linear interpolation of the resolution table
|
||||||
|
float resolutionFactor;
|
||||||
|
int pixels = width * height;
|
||||||
|
for (int i = 0; ; i++) {
|
||||||
|
if (pixels == pixelVals[i]) {
|
||||||
|
// We can bail immediately for exact matches
|
||||||
|
resolutionFactor = factorVals[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (pixels < pixelVals[i]) {
|
||||||
|
if (i == 0) {
|
||||||
|
// Never go below the lowest resolution entry
|
||||||
|
resolutionFactor = factorVals[i];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Interpolate between the entry greater than the chosen resolution (i) and the entry less than the chosen resolution (i-1)
|
||||||
|
resolutionFactor = ((float)(pixels - pixelVals[i-1]) / (pixelVals[i] - pixelVals[i-1])) * (factorVals[i] - factorVals[i-1]) + factorVals[i-1];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (pixelVals[i] == -1) {
|
||||||
|
// Never go above the highest resolution entry
|
||||||
|
resolutionFactor = factorVals[i-1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (int)Math.round(resolutionFactor * frameRateFactor) * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean getDefaultSmallMode(Context context) {
|
public static boolean getDefaultSmallMode(Context context) {
|
||||||
@@ -216,22 +346,71 @@ public class PreferenceConfiguration {
|
|||||||
prefs.getString(FPS_PREF_STRING, DEFAULT_FPS));
|
prefs.getString(FPS_PREF_STRING, DEFAULT_FPS));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int getVideoFormatValue(Context context) {
|
private static FormatOption getVideoFormatValue(Context context) {
|
||||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
|
||||||
String str = prefs.getString(VIDEO_FORMAT_PREF_STRING, DEFAULT_VIDEO_FORMAT);
|
String str = prefs.getString(VIDEO_FORMAT_PREF_STRING, DEFAULT_VIDEO_FORMAT);
|
||||||
if (str.equals("auto")) {
|
if (str.equals("auto")) {
|
||||||
return AUTOSELECT_H265;
|
return FormatOption.AUTO;
|
||||||
|
}
|
||||||
|
else if (str.equals("forceav1")) {
|
||||||
|
return FormatOption.FORCE_AV1;
|
||||||
}
|
}
|
||||||
else if (str.equals("forceh265")) {
|
else if (str.equals("forceh265")) {
|
||||||
return FORCE_H265_ON;
|
return FormatOption.FORCE_HEVC;
|
||||||
}
|
}
|
||||||
else if (str.equals("neverh265")) {
|
else if (str.equals("neverh265")) {
|
||||||
return FORCE_H265_OFF;
|
return FormatOption.FORCE_H264;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Should never get here
|
// Should never get here
|
||||||
return AUTOSELECT_H265;
|
return FormatOption.AUTO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int getFramePacingValue(Context context) {
|
||||||
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
|
||||||
|
// Migrate legacy never drop frames option to the new location
|
||||||
|
if (prefs.contains(LEGACY_DISABLE_FRAME_DROP_PREF_STRING)) {
|
||||||
|
boolean legacyNeverDropFrames = prefs.getBoolean(LEGACY_DISABLE_FRAME_DROP_PREF_STRING, false);
|
||||||
|
prefs.edit()
|
||||||
|
.remove(LEGACY_DISABLE_FRAME_DROP_PREF_STRING)
|
||||||
|
.putString(FRAME_PACING_PREF_STRING, legacyNeverDropFrames ? "balanced" : "latency")
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
String str = prefs.getString(FRAME_PACING_PREF_STRING, DEFAULT_FRAME_PACING);
|
||||||
|
if (str.equals("latency")) {
|
||||||
|
return FRAME_PACING_MIN_LATENCY;
|
||||||
|
}
|
||||||
|
else if (str.equals("balanced")) {
|
||||||
|
return FRAME_PACING_BALANCED;
|
||||||
|
}
|
||||||
|
else if (str.equals("cap-fps")) {
|
||||||
|
return FRAME_PACING_CAP_FPS;
|
||||||
|
}
|
||||||
|
else if (str.equals("smoothness")) {
|
||||||
|
return FRAME_PACING_MAX_SMOOTHNESS;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Should never get here
|
||||||
|
return FRAME_PACING_MIN_LATENCY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnalogStickForScrolling getAnalogStickForScrollingValue(Context context) {
|
||||||
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
|
||||||
|
String str = prefs.getString(ANALOG_SCROLLING_PREF_STRING, DEFAULT_ANALOG_STICK_FOR_SCROLLING);
|
||||||
|
if (str.equals("right")) {
|
||||||
|
return AnalogStickForScrolling.RIGHT;
|
||||||
|
}
|
||||||
|
else if (str.equals("left")) {
|
||||||
|
return AnalogStickForScrolling.LEFT;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return AnalogStickForScrolling.NONE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,9 +426,23 @@ public class PreferenceConfiguration {
|
|||||||
.remove(VIDEO_FORMAT_PREF_STRING)
|
.remove(VIDEO_FORMAT_PREF_STRING)
|
||||||
.remove(ENABLE_HDR_PREF_STRING)
|
.remove(ENABLE_HDR_PREF_STRING)
|
||||||
.remove(UNLOCK_FPS_STRING)
|
.remove(UNLOCK_FPS_STRING)
|
||||||
|
.remove(FULL_RANGE_PREF_STRING)
|
||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void completeLanguagePreferenceMigration(Context context) {
|
||||||
|
// Put our language option back to default which tells us that we've already migrated it
|
||||||
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
prefs.edit().putString(LANGUAGE_PREF_STRING, DEFAULT_LANGUAGE).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isShieldAtvFirmwareWithBrokenHdr() {
|
||||||
|
// This particular Shield TV firmware crashes when using HDR
|
||||||
|
// https://www.nvidia.com/en-us/geforce/forums/notifications/comment/155192/
|
||||||
|
return Build.MANUFACTURER.equalsIgnoreCase("NVIDIA") &&
|
||||||
|
Build.FINGERPRINT.contains("PPR1.180610.011/4079208_2235.1395");
|
||||||
|
}
|
||||||
|
|
||||||
public static PreferenceConfiguration readPreferences(Context context) {
|
public static PreferenceConfiguration readPreferences(Context context) {
|
||||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
PreferenceConfiguration config = new PreferenceConfiguration();
|
PreferenceConfiguration config = new PreferenceConfiguration();
|
||||||
@@ -322,11 +515,33 @@ public class PreferenceConfiguration {
|
|||||||
else {
|
else {
|
||||||
// Use the new preference location
|
// Use the new preference location
|
||||||
String resStr = prefs.getString(RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION);
|
String resStr = prefs.getString(RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION);
|
||||||
|
|
||||||
|
// Convert legacy resolution strings to the new style
|
||||||
|
if (!resStr.contains("x")) {
|
||||||
|
resStr = PreferenceConfiguration.convertFromLegacyResolutionString(resStr);
|
||||||
|
prefs.edit().putString(RESOLUTION_PREF_STRING, resStr).apply();
|
||||||
|
}
|
||||||
|
|
||||||
config.width = PreferenceConfiguration.getWidthFromResolutionString(resStr);
|
config.width = PreferenceConfiguration.getWidthFromResolutionString(resStr);
|
||||||
config.height = PreferenceConfiguration.getHeightFromResolutionString(resStr);
|
config.height = PreferenceConfiguration.getHeightFromResolutionString(resStr);
|
||||||
config.fps = Integer.parseInt(prefs.getString(FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS));
|
config.fps = Integer.parseInt(prefs.getString(FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!prefs.contains(SMALL_ICONS_PREF_STRING)) {
|
||||||
|
// We need to write small icon mode's default to disk for the settings page to display
|
||||||
|
// the current state of the option properly
|
||||||
|
prefs.edit().putBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context)).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prefs.contains(GAMEPAD_MOTION_SENSORS_PREF_STRING) && Build.VERSION.SDK_INT == Build.VERSION_CODES.S) {
|
||||||
|
// Android 12 has a nasty bug that causes crashes when the app touches the InputDevice's
|
||||||
|
// associated InputDeviceSensorManager (just calling getSensorManager() is enough).
|
||||||
|
// As a workaround, we will override the default value for the gamepad motion sensor
|
||||||
|
// option to disabled on Android 12 to reduce the impact of this bug.
|
||||||
|
// https://cs.android.com/android/_/android/platform/frameworks/base/+/8970010a5e9f3dc5c069f56b4147552accfcbbeb
|
||||||
|
prefs.edit().putBoolean(GAMEPAD_MOTION_SENSORS_PREF_STRING, false).apply();
|
||||||
|
}
|
||||||
|
|
||||||
// This must happen after the preferences migration to ensure the preferences are populated
|
// This must happen after the preferences migration to ensure the preferences are populated
|
||||||
config.bitrate = prefs.getInt(BITRATE_PREF_STRING, prefs.getInt(BITRATE_PREF_OLD_STRING, 0) * 1000);
|
config.bitrate = prefs.getInt(BITRATE_PREF_STRING, prefs.getInt(BITRATE_PREF_OLD_STRING, 0) * 1000);
|
||||||
if (config.bitrate == 0) {
|
if (config.bitrate == 0) {
|
||||||
@@ -345,6 +560,9 @@ public class PreferenceConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.videoFormat = getVideoFormatValue(context);
|
config.videoFormat = getVideoFormatValue(context);
|
||||||
|
config.framePacing = getFramePacingValue(context);
|
||||||
|
|
||||||
|
config.analogStickForScrolling = getAnalogStickForScrollingValue(context);
|
||||||
|
|
||||||
config.deadzonePercentage = prefs.getInt(DEADZONE_PREF_STRING, DEFAULT_DEADZONE);
|
config.deadzonePercentage = prefs.getInt(DEADZONE_PREF_STRING, DEFAULT_DEADZONE);
|
||||||
|
|
||||||
@@ -357,14 +575,13 @@ public class PreferenceConfiguration {
|
|||||||
config.enableSops = prefs.getBoolean(SOPS_PREF_STRING, DEFAULT_SOPS);
|
config.enableSops = prefs.getBoolean(SOPS_PREF_STRING, DEFAULT_SOPS);
|
||||||
config.stretchVideo = prefs.getBoolean(STRETCH_PREF_STRING, DEFAULT_STRETCH);
|
config.stretchVideo = prefs.getBoolean(STRETCH_PREF_STRING, DEFAULT_STRETCH);
|
||||||
config.playHostAudio = prefs.getBoolean(HOST_AUDIO_PREF_STRING, DEFAULT_HOST_AUDIO);
|
config.playHostAudio = prefs.getBoolean(HOST_AUDIO_PREF_STRING, DEFAULT_HOST_AUDIO);
|
||||||
config.listMode = prefs.getBoolean(LIST_MODE_PREF_STRING, DEFAULT_LIST_MODE);
|
|
||||||
config.smallIconMode = prefs.getBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context));
|
config.smallIconMode = prefs.getBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context));
|
||||||
config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER);
|
config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER);
|
||||||
config.usbDriver = prefs.getBoolean(USB_DRIVER_PREF_SRING, DEFAULT_USB_DRIVER);
|
config.usbDriver = prefs.getBoolean(USB_DRIVER_PREF_SRING, DEFAULT_USB_DRIVER);
|
||||||
config.onscreenController = prefs.getBoolean(ONSCREEN_CONTROLLER_PREF_STRING, ONSCREEN_CONTROLLER_DEFAULT);
|
config.onscreenController = prefs.getBoolean(ONSCREEN_CONTROLLER_PREF_STRING, ONSCREEN_CONTROLLER_DEFAULT);
|
||||||
config.onlyL3R3 = prefs.getBoolean(ONLY_L3_R3_PREF_STRING, ONLY_L3_R3_DEFAULT);
|
config.onlyL3R3 = prefs.getBoolean(ONLY_L3_R3_PREF_STRING, ONLY_L3_R3_DEFAULT);
|
||||||
config.disableFrameDrop = prefs.getBoolean(DISABLE_FRAME_DROP_PREF_STRING, DEFAULT_DISABLE_FRAME_DROP);
|
config.showGuideButton = prefs.getBoolean(SHOW_GUIDE_BUTTON_PREF_STRING, SHOW_GUIDE_BUTTON_DEFAULT);
|
||||||
config.enableHdr = prefs.getBoolean(ENABLE_HDR_PREF_STRING, DEFAULT_ENABLE_HDR);
|
config.enableHdr = prefs.getBoolean(ENABLE_HDR_PREF_STRING, DEFAULT_ENABLE_HDR) && !isShieldAtvFirmwareWithBrokenHdr();
|
||||||
config.enablePip = prefs.getBoolean(ENABLE_PIP_PREF_STRING, DEFAULT_ENABLE_PIP);
|
config.enablePip = prefs.getBoolean(ENABLE_PIP_PREF_STRING, DEFAULT_ENABLE_PIP);
|
||||||
config.enablePerfOverlay = prefs.getBoolean(ENABLE_PERF_OVERLAY_STRING, DEFAULT_ENABLE_PERF_OVERLAY);
|
config.enablePerfOverlay = prefs.getBoolean(ENABLE_PERF_OVERLAY_STRING, DEFAULT_ENABLE_PERF_OVERLAY);
|
||||||
config.bindAllUsb = prefs.getBoolean(BIND_ALL_USB_STRING, DEFAULT_BIND_ALL_USB);
|
config.bindAllUsb = prefs.getBoolean(BIND_ALL_USB_STRING, DEFAULT_BIND_ALL_USB);
|
||||||
@@ -373,8 +590,17 @@ public class PreferenceConfiguration {
|
|||||||
config.unlockFps = prefs.getBoolean(UNLOCK_FPS_STRING, DEFAULT_UNLOCK_FPS);
|
config.unlockFps = prefs.getBoolean(UNLOCK_FPS_STRING, DEFAULT_UNLOCK_FPS);
|
||||||
config.vibrateOsc = prefs.getBoolean(VIBRATE_OSC_PREF_STRING, DEFAULT_VIBRATE_OSC);
|
config.vibrateOsc = prefs.getBoolean(VIBRATE_OSC_PREF_STRING, DEFAULT_VIBRATE_OSC);
|
||||||
config.vibrateFallbackToDevice = prefs.getBoolean(VIBRATE_FALLBACK_PREF_STRING, DEFAULT_VIBRATE_FALLBACK);
|
config.vibrateFallbackToDevice = prefs.getBoolean(VIBRATE_FALLBACK_PREF_STRING, DEFAULT_VIBRATE_FALLBACK);
|
||||||
|
config.vibrateFallbackToDeviceStrength = prefs.getInt(VIBRATE_FALLBACK_STRENGTH_PREF_STRING, DEFAULT_VIBRATE_FALLBACK_STRENGTH);
|
||||||
config.flipFaceButtons = prefs.getBoolean(FLIP_FACE_BUTTONS_PREF_STRING, DEFAULT_FLIP_FACE_BUTTONS);
|
config.flipFaceButtons = prefs.getBoolean(FLIP_FACE_BUTTONS_PREF_STRING, DEFAULT_FLIP_FACE_BUTTONS);
|
||||||
config.touchscreenTrackpad = prefs.getBoolean(TOUCHSCREEN_TRACKPAD_PREF_STRING, DEFAULT_TOUCHSCREEN_TRACKPAD);
|
config.touchscreenTrackpad = prefs.getBoolean(TOUCHSCREEN_TRACKPAD_PREF_STRING, DEFAULT_TOUCHSCREEN_TRACKPAD);
|
||||||
|
config.enableLatencyToast = prefs.getBoolean(LATENCY_TOAST_PREF_STRING, DEFAULT_LATENCY_TOAST);
|
||||||
|
config.absoluteMouseMode = prefs.getBoolean(ABSOLUTE_MOUSE_MODE_PREF_STRING, DEFAULT_ABSOLUTE_MOUSE_MODE);
|
||||||
|
config.enableAudioFx = prefs.getBoolean(ENABLE_AUDIO_FX_PREF_STRING, DEFAULT_ENABLE_AUDIO_FX);
|
||||||
|
config.reduceRefreshRate = prefs.getBoolean(REDUCE_REFRESH_RATE_PREF_STRING, DEFAULT_REDUCE_REFRESH_RATE);
|
||||||
|
config.fullRange = prefs.getBoolean(FULL_RANGE_PREF_STRING, DEFAULT_FULL_RANGE);
|
||||||
|
config.gamepadTouchpadAsMouse = prefs.getBoolean(GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING, DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE);
|
||||||
|
config.gamepadMotionSensors = prefs.getBoolean(GAMEPAD_MOTION_SENSORS_PREF_STRING, DEFAULT_GAMEPAD_MOTION_SENSORS);
|
||||||
|
config.gamepadMotionSensorsFallbackToDevice = prefs.getBoolean(GAMEPAD_MOTION_FALLBACK_PREF_STRING, DEFAULT_GAMEPAD_MOTION_FALLBACK);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import android.widget.LinearLayout;
|
|||||||
import android.widget.SeekBar;
|
import android.widget.SeekBar;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
// Based on a Stack Overflow example: http://stackoverflow.com/questions/1974193/slider-on-my-preferencescreen
|
// Based on a Stack Overflow example: http://stackoverflow.com/questions/1974193/slider-on-my-preferencescreen
|
||||||
public class SeekBarPreference extends DialogPreference
|
public class SeekBarPreference extends DialogPreference
|
||||||
{
|
{
|
||||||
@@ -30,6 +32,8 @@ public class SeekBarPreference extends DialogPreference
|
|||||||
private final int maxValue;
|
private final int maxValue;
|
||||||
private final int minValue;
|
private final int minValue;
|
||||||
private final int stepSize;
|
private final int stepSize;
|
||||||
|
private final int keyStepSize;
|
||||||
|
private final int divisor;
|
||||||
private int currentValue;
|
private int currentValue;
|
||||||
|
|
||||||
public SeekBarPreference(Context context, AttributeSet attrs) {
|
public SeekBarPreference(Context context, AttributeSet attrs) {
|
||||||
@@ -59,6 +63,8 @@ public class SeekBarPreference extends DialogPreference
|
|||||||
maxValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "max", 100);
|
maxValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "max", 100);
|
||||||
minValue = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "min", 1);
|
minValue = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "min", 1);
|
||||||
stepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "step", 1);
|
stepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "step", 1);
|
||||||
|
divisor = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "divisor", 1);
|
||||||
|
keyStepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "keyStep", 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -101,7 +107,14 @@ public class SeekBarPreference extends DialogPreference
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String t = String.valueOf(value);
|
String t;
|
||||||
|
if (divisor != 1) {
|
||||||
|
float floatValue = roundedValue / (float)divisor;
|
||||||
|
t = String.format((Locale)null, "%.1f", floatValue);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
t = String.valueOf(value);
|
||||||
|
}
|
||||||
valueText.setText(suffix == null ? t : t.concat(suffix.length() > 1 ? " "+suffix : suffix));
|
valueText.setText(suffix == null ? t : t.concat(suffix.length() > 1 ? " "+suffix : suffix));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +132,9 @@ public class SeekBarPreference extends DialogPreference
|
|||||||
}
|
}
|
||||||
|
|
||||||
seekBar.setMax(maxValue);
|
seekBar.setMax(maxValue);
|
||||||
|
if (keyStepSize != 0) {
|
||||||
|
seekBar.setKeyProgressIncrement(keyStepSize);
|
||||||
|
}
|
||||||
seekBar.setProgress(currentValue);
|
seekBar.setProgress(currentValue);
|
||||||
|
|
||||||
return layout;
|
return layout;
|
||||||
@@ -128,6 +144,9 @@ public class SeekBarPreference extends DialogPreference
|
|||||||
protected void onBindDialogView(View v) {
|
protected void onBindDialogView(View v) {
|
||||||
super.onBindDialogView(v);
|
super.onBindDialogView(v);
|
||||||
seekBar.setMax(maxValue);
|
seekBar.setMax(maxValue);
|
||||||
|
if (keyStepSize != 0) {
|
||||||
|
seekBar.setKeyProgressIncrement(keyStepSize);
|
||||||
|
}
|
||||||
seekBar.setProgress(currentValue);
|
seekBar.setProgress(currentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,57 @@
|
|||||||
package com.limelight.preferences;
|
package com.limelight.preferences;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.content.res.Configuration;
|
||||||
import android.media.MediaCodecInfo;
|
import android.media.MediaCodecInfo;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
|
import android.os.Vibrator;
|
||||||
|
import android.preference.CheckBoxPreference;
|
||||||
import android.preference.ListPreference;
|
import android.preference.ListPreference;
|
||||||
import android.preference.Preference;
|
import android.preference.Preference;
|
||||||
import android.preference.PreferenceCategory;
|
import android.preference.PreferenceCategory;
|
||||||
import android.preference.PreferenceFragment;
|
import android.preference.PreferenceFragment;
|
||||||
import android.preference.PreferenceManager;
|
import android.preference.PreferenceManager;
|
||||||
import android.preference.PreferenceScreen;
|
import android.preference.PreferenceScreen;
|
||||||
|
import android.util.DisplayMetrics;
|
||||||
import android.util.Range;
|
import android.util.Range;
|
||||||
import android.view.Display;
|
import android.view.Display;
|
||||||
|
import android.view.DisplayCutout;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
import android.view.WindowInsets;
|
||||||
|
|
||||||
import com.limelight.LimeLog;
|
import com.limelight.LimeLog;
|
||||||
import com.limelight.PcView;
|
import com.limelight.PcView;
|
||||||
import com.limelight.R;
|
import com.limelight.R;
|
||||||
import com.limelight.binding.video.MediaCodecHelper;
|
import com.limelight.binding.video.MediaCodecHelper;
|
||||||
|
import com.limelight.utils.Dialog;
|
||||||
import com.limelight.utils.UiHelper;
|
import com.limelight.utils.UiHelper;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
public class StreamSettings extends Activity {
|
public class StreamSettings extends Activity {
|
||||||
private PreferenceConfiguration previousPrefs;
|
private PreferenceConfiguration previousPrefs;
|
||||||
|
private int previousDisplayPixelCount;
|
||||||
|
|
||||||
|
// HACK for Android 9
|
||||||
|
static DisplayCutout displayCutoutP;
|
||||||
|
|
||||||
void reloadSettings() {
|
void reloadSettings() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
Display.Mode mode = getWindowManager().getDefaultDisplay().getMode();
|
||||||
|
previousDisplayPixelCount = mode.getPhysicalWidth() * mode.getPhysicalHeight();
|
||||||
|
}
|
||||||
getFragmentManager().beginTransaction().replace(
|
getFragmentManager().beginTransaction().replace(
|
||||||
R.id.stream_settings, new SettingsFragment()
|
R.id.stream_settings, new SettingsFragment()
|
||||||
).commit();
|
).commitAllowingStateLoss();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -43,28 +63,67 @@ public class StreamSettings extends Activity {
|
|||||||
UiHelper.setLocale(this);
|
UiHelper.setLocale(this);
|
||||||
|
|
||||||
setContentView(R.layout.activity_stream_settings);
|
setContentView(R.layout.activity_stream_settings);
|
||||||
reloadSettings();
|
|
||||||
|
|
||||||
UiHelper.notifyNewRootView(this);
|
UiHelper.notifyNewRootView(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
public void onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow();
|
||||||
|
|
||||||
|
// We have to use this hack on Android 9 because we don't have Display.getCutout()
|
||||||
|
// which was added in Android 10.
|
||||||
|
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
|
||||||
|
// Insets can be null when the activity is recreated on screen rotation
|
||||||
|
// https://stackoverflow.com/questions/61241255/windowinsets-getdisplaycutout-is-null-everywhere-except-within-onattachedtowindo
|
||||||
|
WindowInsets insets = getWindow().getDecorView().getRootWindowInsets();
|
||||||
|
if (insets != null) {
|
||||||
|
displayCutoutP = insets.getDisplayCutout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConfigurationChanged(Configuration newConfig) {
|
||||||
|
super.onConfigurationChanged(newConfig);
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
Display.Mode mode = getWindowManager().getDefaultDisplay().getMode();
|
||||||
|
|
||||||
|
// If the display's physical pixel count has changed, we consider that it's a new display
|
||||||
|
// and we should reload our settings (which include display-dependent values).
|
||||||
|
//
|
||||||
|
// NB: We aren't using displayId here because that stays the same (DEFAULT_DISPLAY) when
|
||||||
|
// switching between screens on a foldable device.
|
||||||
|
if (mode.getPhysicalWidth() * mode.getPhysicalHeight() != previousDisplayPixelCount) {
|
||||||
|
reloadSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
// NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true"
|
||||||
public void onBackPressed() {
|
public void onBackPressed() {
|
||||||
finish();
|
finish();
|
||||||
|
|
||||||
// Check for changes that require a UI reload to take effect
|
// Language changes are handled via configuration changes in Android 13+,
|
||||||
PreferenceConfiguration newPrefs = PreferenceConfiguration.readPreferences(this);
|
// so manual activity relaunching is no longer required.
|
||||||
if (newPrefs.listMode != previousPrefs.listMode ||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
newPrefs.smallIconMode != previousPrefs.smallIconMode ||
|
PreferenceConfiguration newPrefs = PreferenceConfiguration.readPreferences(this);
|
||||||
!newPrefs.language.equals(previousPrefs.language)) {
|
if (!newPrefs.language.equals(previousPrefs.language)) {
|
||||||
// Restart the PC view to apply UI changes
|
// Restart the PC view to apply UI changes
|
||||||
Intent intent = new Intent(this, PcView.class);
|
Intent intent = new Intent(this, PcView.class);
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
|
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
startActivity(intent, null);
|
startActivity(intent, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SettingsFragment extends PreferenceFragment {
|
public static class SettingsFragment extends PreferenceFragment {
|
||||||
|
private int nativeResolutionStartIndex = Integer.MAX_VALUE;
|
||||||
|
private boolean nativeFramerateShown = false;
|
||||||
|
|
||||||
private void setValue(String preferenceKey, String value) {
|
private void setValue(String preferenceKey, String value) {
|
||||||
ListPreference pref = (ListPreference) findPreference(preferenceKey);
|
ListPreference pref = (ListPreference) findPreference(preferenceKey);
|
||||||
@@ -72,6 +131,88 @@ public class StreamSettings extends Activity {
|
|||||||
pref.setValue(value);
|
pref.setValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void appendPreferenceEntry(ListPreference pref, String newEntryName, String newEntryValue) {
|
||||||
|
CharSequence[] newEntries = Arrays.copyOf(pref.getEntries(), pref.getEntries().length + 1);
|
||||||
|
CharSequence[] newValues = Arrays.copyOf(pref.getEntryValues(), pref.getEntryValues().length + 1);
|
||||||
|
|
||||||
|
// Add the new option
|
||||||
|
newEntries[newEntries.length - 1] = newEntryName;
|
||||||
|
newValues[newValues.length - 1] = newEntryValue;
|
||||||
|
|
||||||
|
pref.setEntries(newEntries);
|
||||||
|
pref.setEntryValues(newValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addNativeResolutionEntry(int nativeWidth, int nativeHeight, boolean insetsRemoved, boolean portrait) {
|
||||||
|
ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING);
|
||||||
|
|
||||||
|
String newName;
|
||||||
|
|
||||||
|
if (insetsRemoved) {
|
||||||
|
newName = getResources().getString(R.string.resolution_prefix_native_fullscreen);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newName = getResources().getString(R.string.resolution_prefix_native);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PreferenceConfiguration.isSquarishScreen(nativeWidth, nativeHeight)) {
|
||||||
|
if (portrait) {
|
||||||
|
newName += " " + getResources().getString(R.string.resolution_prefix_native_portrait);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newName += " " + getResources().getString(R.string.resolution_prefix_native_landscape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newName += " ("+nativeWidth+"x"+nativeHeight+")";
|
||||||
|
|
||||||
|
String newValue = nativeWidth+"x"+nativeHeight;
|
||||||
|
|
||||||
|
// Check if the native resolution is already present
|
||||||
|
for (CharSequence value : pref.getEntryValues()) {
|
||||||
|
if (newValue.equals(value.toString())) {
|
||||||
|
// It is present in the default list, so don't add it again
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pref.getEntryValues().length < nativeResolutionStartIndex) {
|
||||||
|
nativeResolutionStartIndex = pref.getEntryValues().length;
|
||||||
|
}
|
||||||
|
appendPreferenceEntry(pref, newName, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addNativeResolutionEntries(int nativeWidth, int nativeHeight, boolean insetsRemoved) {
|
||||||
|
if (PreferenceConfiguration.isSquarishScreen(nativeWidth, nativeHeight)) {
|
||||||
|
addNativeResolutionEntry(nativeHeight, nativeWidth, insetsRemoved, true);
|
||||||
|
}
|
||||||
|
addNativeResolutionEntry(nativeWidth, nativeHeight, insetsRemoved, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addNativeFrameRateEntry(float framerate) {
|
||||||
|
int frameRateRounded = Math.round(framerate);
|
||||||
|
if (frameRateRounded == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.FPS_PREF_STRING);
|
||||||
|
String fpsValue = Integer.toString(frameRateRounded);
|
||||||
|
String fpsName = getResources().getString(R.string.resolution_prefix_native) +
|
||||||
|
" (" + fpsValue + " " + getResources().getString(R.string.fps_suffix_fps) + ")";
|
||||||
|
|
||||||
|
// Check if the native frame rate is already present
|
||||||
|
for (CharSequence value : pref.getEntryValues()) {
|
||||||
|
if (fpsValue.equals(value.toString())) {
|
||||||
|
// It is present in the default list, so don't add it again
|
||||||
|
nativeFramerateShown = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPreferenceEntry(pref, fpsName, fpsValue);
|
||||||
|
nativeFramerateShown = true;
|
||||||
|
}
|
||||||
|
|
||||||
private void removeValue(String preferenceKey, String value, Runnable onMatched) {
|
private void removeValue(String preferenceKey, String value, Runnable onMatched) {
|
||||||
int matchingCount = 0;
|
int matchingCount = 0;
|
||||||
|
|
||||||
@@ -108,8 +249,6 @@ public class StreamSettings extends Activity {
|
|||||||
pref.setEntryValues(entryValues);
|
pref.setEntryValues(entryValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private void resetBitrateToDefault(SharedPreferences prefs, String res, String fps) {
|
private void resetBitrateToDefault(SharedPreferences prefs, String res, String fps) {
|
||||||
if (res == null) {
|
if (res == null) {
|
||||||
res = prefs.getString(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION);
|
res = prefs.getString(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION);
|
||||||
@@ -139,36 +278,118 @@ public class StreamSettings extends Activity {
|
|||||||
PreferenceScreen screen = getPreferenceScreen();
|
PreferenceScreen screen = getPreferenceScreen();
|
||||||
|
|
||||||
// hide on-screen controls category on non touch screen devices
|
// hide on-screen controls category on non touch screen devices
|
||||||
if (!getActivity().getPackageManager().
|
if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) {
|
||||||
hasSystemFeature("android.hardware.touchscreen")) {
|
PreferenceCategory category =
|
||||||
{
|
(PreferenceCategory) findPreference("category_onscreen_controls");
|
||||||
PreferenceCategory category =
|
screen.removePreference(category);
|
||||||
(PreferenceCategory) findPreference("category_onscreen_controls");
|
|
||||||
screen.removePreference(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
PreferenceCategory category =
|
|
||||||
(PreferenceCategory) findPreference("category_input_settings");
|
|
||||||
category.removePreference(findPreference("checkbox_touchscreen_trackpad"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove PiP mode on devices pre-Oreo
|
// Hide remote desktop mouse mode on pre-Oreo (which doesn't have pointer capture)
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
// and NVIDIA SHIELD devices (which support raw mouse input in pointer capture mode)
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
|
getActivity().getPackageManager().hasSystemFeature("com.nvidia.feature.shield")) {
|
||||||
PreferenceCategory category =
|
PreferenceCategory category =
|
||||||
(PreferenceCategory) findPreference("category_basic_settings");
|
(PreferenceCategory) findPreference("category_input_settings");
|
||||||
|
category.removePreference(findPreference("checkbox_absolute_mouse_mode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide gamepad motion sensor option when running on OSes before Android 12.
|
||||||
|
// Support for motion, LED, battery, and other extensions were introduced in S.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
PreferenceCategory category =
|
||||||
|
(PreferenceCategory) findPreference("category_gamepad_settings");
|
||||||
|
category.removePreference(findPreference("checkbox_gamepad_motion_sensors"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide gamepad motion sensor fallback option if the device has no gyro or accelerometer
|
||||||
|
if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER) &&
|
||||||
|
!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_GYROSCOPE)) {
|
||||||
|
PreferenceCategory category =
|
||||||
|
(PreferenceCategory) findPreference("category_gamepad_settings");
|
||||||
|
category.removePreference(findPreference("checkbox_gamepad_motion_fallback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide USB driver options on devices without USB host support
|
||||||
|
if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_USB_HOST)) {
|
||||||
|
PreferenceCategory category =
|
||||||
|
(PreferenceCategory) findPreference("category_gamepad_settings");
|
||||||
|
category.removePreference(findPreference("checkbox_usb_bind_all"));
|
||||||
|
category.removePreference(findPreference("checkbox_usb_driver"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove PiP mode on devices pre-Oreo, where the feature is not available (some low RAM devices),
|
||||||
|
// and on Fire OS where it violates the Amazon App Store guidelines for some reason.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
|
!getActivity().getPackageManager().hasSystemFeature("android.software.picture_in_picture") ||
|
||||||
|
getActivity().getPackageManager().hasSystemFeature("com.amazon.software.fireos")) {
|
||||||
|
PreferenceCategory category =
|
||||||
|
(PreferenceCategory) findPreference("category_ui_settings");
|
||||||
category.removePreference(findPreference("checkbox_enable_pip"));
|
category.removePreference(findPreference("checkbox_enable_pip"));
|
||||||
}
|
}
|
||||||
|
|
||||||
int maxSupportedFps = 0;
|
// Fire TV apps are not allowed to use WebViews or browsers, so hide the Help category
|
||||||
|
/*if (getActivity().getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) {
|
||||||
|
PreferenceCategory category =
|
||||||
|
(PreferenceCategory) findPreference("category_help");
|
||||||
|
screen.removePreference(category);
|
||||||
|
}*/
|
||||||
|
PreferenceCategory category_gamepad_settings =
|
||||||
|
(PreferenceCategory) findPreference("category_gamepad_settings");
|
||||||
|
// Remove the vibration options if the device can't vibrate
|
||||||
|
if (!((Vibrator)getActivity().getSystemService(Context.VIBRATOR_SERVICE)).hasVibrator()) {
|
||||||
|
category_gamepad_settings.removePreference(findPreference("checkbox_vibrate_fallback"));
|
||||||
|
category_gamepad_settings.removePreference(findPreference("seekbar_vibrate_fallback_strength"));
|
||||||
|
// The entire OSC category may have already been removed by the touchscreen check above
|
||||||
|
PreferenceCategory category = (PreferenceCategory) findPreference("category_onscreen_controls");
|
||||||
|
if (category != null) {
|
||||||
|
category.removePreference(findPreference("checkbox_vibrate_osc"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
|
!((Vibrator)getActivity().getSystemService(Context.VIBRATOR_SERVICE)).hasAmplitudeControl() ) {
|
||||||
|
// Remove the vibration strength selector of the device doesn't have amplitude control
|
||||||
|
category_gamepad_settings.removePreference(findPreference("seekbar_vibrate_fallback_strength"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Display display = getActivity().getWindowManager().getDefaultDisplay();
|
||||||
|
float maxSupportedFps = display.getRefreshRate();
|
||||||
|
|
||||||
// Hide non-supported resolution/FPS combinations
|
// Hide non-supported resolution/FPS combinations
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
Display display = getActivity().getWindowManager().getDefaultDisplay();
|
|
||||||
|
|
||||||
int maxSupportedResW = 0;
|
int maxSupportedResW = 0;
|
||||||
|
|
||||||
|
// Add a native resolution with any insets included for users that don't want content
|
||||||
|
// behind the notch of their display
|
||||||
|
boolean hasInsets = false;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
DisplayCutout cutout;
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// Use the much nicer Display.getCutout() API on Android 10+
|
||||||
|
cutout = display.getCutout();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Android 9 only
|
||||||
|
cutout = displayCutoutP;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cutout != null) {
|
||||||
|
int widthInsets = cutout.getSafeInsetLeft() + cutout.getSafeInsetRight();
|
||||||
|
int heightInsets = cutout.getSafeInsetBottom() + cutout.getSafeInsetTop();
|
||||||
|
|
||||||
|
if (widthInsets != 0 || heightInsets != 0) {
|
||||||
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
|
display.getRealMetrics(metrics);
|
||||||
|
|
||||||
|
int width = Math.max(metrics.widthPixels - widthInsets, metrics.heightPixels - heightInsets);
|
||||||
|
int height = Math.min(metrics.widthPixels - widthInsets, metrics.heightPixels - heightInsets);
|
||||||
|
|
||||||
|
addNativeResolutionEntries(width, height, false);
|
||||||
|
hasInsets = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Always allow resolutions that are smaller or equal to the active
|
// Always allow resolutions that are smaller or equal to the active
|
||||||
// display resolution because decoders can report total non-sense to us.
|
// display resolution because decoders can report total non-sense to us.
|
||||||
// For example, a p201 device reports:
|
// For example, a p201 device reports:
|
||||||
@@ -184,6 +405,13 @@ public class StreamSettings extends Activity {
|
|||||||
int width = Math.max(candidate.getPhysicalWidth(), candidate.getPhysicalHeight());
|
int width = Math.max(candidate.getPhysicalWidth(), candidate.getPhysicalHeight());
|
||||||
int height = Math.min(candidate.getPhysicalWidth(), candidate.getPhysicalHeight());
|
int height = Math.min(candidate.getPhysicalWidth(), candidate.getPhysicalHeight());
|
||||||
|
|
||||||
|
// Some TVs report strange values here, so let's avoid native resolutions on a TV
|
||||||
|
// unless they report greater than 4K resolutions.
|
||||||
|
if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
|
||||||
|
(width > 3840 || height > 2160)) {
|
||||||
|
addNativeResolutionEntries(width, height, hasInsets);
|
||||||
|
}
|
||||||
|
|
||||||
if ((width >= 3840 || height >= 2160) && maxSupportedResW < 3840) {
|
if ((width >= 3840 || height >= 2160) && maxSupportedResW < 3840) {
|
||||||
maxSupportedResW = 3840;
|
maxSupportedResW = 3840;
|
||||||
}
|
}
|
||||||
@@ -195,7 +423,7 @@ public class StreamSettings extends Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (candidate.getRefreshRate() > maxSupportedFps) {
|
if (candidate.getRefreshRate() > maxSupportedFps) {
|
||||||
maxSupportedFps = (int)candidate.getRefreshRate();
|
maxSupportedFps = candidate.getRefreshRate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,33 +477,33 @@ public class StreamSettings extends Activity {
|
|||||||
if (maxSupportedResW != 0) {
|
if (maxSupportedResW != 0) {
|
||||||
if (maxSupportedResW < 3840) {
|
if (maxSupportedResW < 3840) {
|
||||||
// 4K is unsupported
|
// 4K is unsupported
|
||||||
removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, "4K", new Runnable() {
|
removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_4K, new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||||
setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, "1440p");
|
setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P);
|
||||||
resetBitrateToDefault(prefs, null, null);
|
resetBitrateToDefault(prefs, null, null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (maxSupportedResW < 2560) {
|
if (maxSupportedResW < 2560) {
|
||||||
// 1440p is unsupported
|
// 1440p is unsupported
|
||||||
removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, "1440p", new Runnable() {
|
removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P, new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||||
setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, "1080p");
|
setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P);
|
||||||
resetBitrateToDefault(prefs, null, null);
|
resetBitrateToDefault(prefs, null, null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (maxSupportedResW < 1920) {
|
if (maxSupportedResW < 1920) {
|
||||||
// 1080p is unsupported
|
// 1080p is unsupported
|
||||||
removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, "1080p", new Runnable() {
|
removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P, new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||||
setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, "720p");
|
setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_720P);
|
||||||
resetBitrateToDefault(prefs, null, null);
|
resetBitrateToDefault(prefs, null, null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -283,6 +511,15 @@ public class StreamSettings extends Activity {
|
|||||||
// Never remove 720p
|
// Never remove 720p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
// We can get the true metrics via the getRealMetrics() function (unlike the lies
|
||||||
|
// that getWidth() and getHeight() tell to us).
|
||||||
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
|
display.getRealMetrics(metrics);
|
||||||
|
int width = Math.max(metrics.widthPixels, metrics.heightPixels);
|
||||||
|
int height = Math.min(metrics.widthPixels, metrics.heightPixels);
|
||||||
|
addNativeResolutionEntries(width, height, false);
|
||||||
|
}
|
||||||
|
|
||||||
if (!PreferenceConfiguration.readPreferences(this.getActivity()).unlockFps) {
|
if (!PreferenceConfiguration.readPreferences(this.getActivity()).unlockFps) {
|
||||||
// We give some extra room in case the FPS is rounded down
|
// We give some extra room in case the FPS is rounded down
|
||||||
@@ -309,50 +546,31 @@ public class StreamSettings extends Activity {
|
|||||||
}
|
}
|
||||||
// Never remove 30 FPS or 60 FPS
|
// Never remove 30 FPS or 60 FPS
|
||||||
}
|
}
|
||||||
|
addNativeFrameRateEntry(maxSupportedFps);
|
||||||
// Android L introduces proper 7.1 surround sound support. Remove the 7.1 option
|
|
||||||
// for earlier versions of Android to prevent AudioTrack initialization issues.
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
LimeLog.info("Excluding 7.1 surround sound option based on OS");
|
|
||||||
removeValue(PreferenceConfiguration.AUDIO_CONFIG_PREF_STRING, "71", new Runnable() {
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
setValue(PreferenceConfiguration.AUDIO_CONFIG_PREF_STRING, "51");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android L introduces the drop duplicate behavior of releaseOutputBuffer()
|
// Android L introduces the drop duplicate behavior of releaseOutputBuffer()
|
||||||
// that the unlock FPS option relies on to not massively increase latency.
|
// that the unlock FPS option relies on to not massively increase latency.
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
findPreference(PreferenceConfiguration.UNLOCK_FPS_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
|
||||||
LimeLog.info("Excluding unlock FPS toggle based on OS");
|
@Override
|
||||||
PreferenceCategory category =
|
public boolean onPreferenceChange(Preference preference, Object newValue) {
|
||||||
(PreferenceCategory) findPreference("category_basic_settings");
|
// HACK: We need to let the preference change succeed before reinitializing to ensure
|
||||||
category.removePreference(findPreference("checkbox_unlock_fps"));
|
// it's reflected in the new layout.
|
||||||
}
|
final Handler h = new Handler();
|
||||||
else {
|
h.postDelayed(new Runnable() {
|
||||||
findPreference(PreferenceConfiguration.UNLOCK_FPS_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
|
@Override
|
||||||
@Override
|
public void run() {
|
||||||
public boolean onPreferenceChange(Preference preference, Object newValue) {
|
// Ensure the activity is still open when this timeout expires
|
||||||
// HACK: We need to let the preference change succeed before reinitializing to ensure
|
StreamSettings settingsActivity = (StreamSettings) SettingsFragment.this.getActivity();
|
||||||
// it's reflected in the new layout.
|
if (settingsActivity != null) {
|
||||||
final Handler h = new Handler();
|
settingsActivity.reloadSettings();
|
||||||
h.postDelayed(new Runnable() {
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
// Ensure the activity is still open when this timeout expires
|
|
||||||
StreamSettings settingsActivity = (StreamSettings)SettingsFragment.this.getActivity();
|
|
||||||
if (settingsActivity != null) {
|
|
||||||
settingsActivity.reloadSettings();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 500);
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
// Allow the original preference change to take place
|
// Allow the original preference change to take place
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Remove HDR preference for devices below Nougat
|
// Remove HDR preference for devices below Nougat
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||||
@@ -362,7 +580,6 @@ public class StreamSettings extends Activity {
|
|||||||
category.removePreference(findPreference("checkbox_enable_hdr"));
|
category.removePreference(findPreference("checkbox_enable_hdr"));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Display display = getActivity().getWindowManager().getDefaultDisplay();
|
|
||||||
Display.HdrCapabilities hdrCaps = display.getHdrCapabilities();
|
Display.HdrCapabilities hdrCaps = display.getHdrCapabilities();
|
||||||
|
|
||||||
// We must now ensure our display is compatible with HDR10
|
// We must now ensure our display is compatible with HDR10
|
||||||
@@ -372,6 +589,7 @@ public class StreamSettings extends Activity {
|
|||||||
for (int hdrType : hdrCaps.getSupportedHdrTypes()) {
|
for (int hdrType : hdrCaps.getSupportedHdrTypes()) {
|
||||||
if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) {
|
if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) {
|
||||||
foundHdr10 = true;
|
foundHdr10 = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,6 +600,15 @@ public class StreamSettings extends Activity {
|
|||||||
(PreferenceCategory) findPreference("category_advanced_settings");
|
(PreferenceCategory) findPreference("category_advanced_settings");
|
||||||
category.removePreference(findPreference("checkbox_enable_hdr"));
|
category.removePreference(findPreference("checkbox_enable_hdr"));
|
||||||
}
|
}
|
||||||
|
else if (PreferenceConfiguration.isShieldAtvFirmwareWithBrokenHdr()) {
|
||||||
|
LimeLog.info("Disabling HDR toggle on old broken SHIELD TV firmware");
|
||||||
|
PreferenceCategory category =
|
||||||
|
(PreferenceCategory) findPreference("category_advanced_settings");
|
||||||
|
CheckBoxPreference hdrPref = (CheckBoxPreference) category.findPreference("checkbox_enable_hdr");
|
||||||
|
hdrPref.setEnabled(false);
|
||||||
|
hdrPref.setChecked(false);
|
||||||
|
hdrPref.setSummary("Update the firmware on your NVIDIA SHIELD Android TV to enable HDR");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a listener to the FPS and resolution preference
|
// Add a listener to the FPS and resolution preference
|
||||||
@@ -392,6 +619,25 @@ public class StreamSettings extends Activity {
|
|||||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||||
String valueStr = (String) newValue;
|
String valueStr = (String) newValue;
|
||||||
|
|
||||||
|
// Detect if this value is the native resolution option
|
||||||
|
CharSequence[] values = ((ListPreference)preference).getEntryValues();
|
||||||
|
boolean isNativeRes = true;
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
// Look for a match prior to the start of the native resolution entries
|
||||||
|
if (valueStr.equals(values[i].toString()) && i < nativeResolutionStartIndex) {
|
||||||
|
isNativeRes = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is native resolution, show the warning dialog
|
||||||
|
if (isNativeRes) {
|
||||||
|
Dialog.displayDialog(getActivity(),
|
||||||
|
getResources().getString(R.string.title_native_res_dialog),
|
||||||
|
getResources().getString(R.string.text_native_res_dialog),
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
// Write the new bitrate value
|
// Write the new bitrate value
|
||||||
resetBitrateToDefault(prefs, valueStr, null);
|
resetBitrateToDefault(prefs, valueStr, null);
|
||||||
|
|
||||||
@@ -405,6 +651,15 @@ public class StreamSettings extends Activity {
|
|||||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||||
String valueStr = (String) newValue;
|
String valueStr = (String) newValue;
|
||||||
|
|
||||||
|
// If this is native frame rate, show the warning dialog
|
||||||
|
CharSequence[] values = ((ListPreference)preference).getEntryValues();
|
||||||
|
if (nativeFramerateShown && values[values.length - 1].toString().equals(newValue.toString())) {
|
||||||
|
Dialog.displayDialog(getActivity(),
|
||||||
|
getResources().getString(R.string.title_native_fps_dialog),
|
||||||
|
getResources().getString(R.string.text_native_res_dialog),
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
// Write the new bitrate value
|
// Write the new bitrate value
|
||||||
resetBitrateToDefault(prefs, null, valueStr);
|
resetBitrateToDefault(prefs, null, valueStr);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.limelight.preferences;
|
||||||
|
|
||||||
|
import android.annotation.TargetApi;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.preference.Preference;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
|
||||||
|
import com.limelight.utils.HelpLauncher;
|
||||||
|
|
||||||
|
public class WebLauncherPreference extends Preference {
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
public WebLauncherPreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
initialize(attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebLauncherPreference(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
initialize(attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebLauncherPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||||
|
super(context, attrs, defStyleAttr, defStyleRes);
|
||||||
|
initialize(attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initialize(AttributeSet attrs) {
|
||||||
|
if (attrs == null) {
|
||||||
|
throw new IllegalStateException("WebLauncherPreference must have attributes!");
|
||||||
|
}
|
||||||
|
|
||||||
|
url = attrs.getAttributeValue(null, "url");
|
||||||
|
if (url == null) {
|
||||||
|
throw new IllegalStateException("WebLauncherPreference must have 'url' attribute!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick() {
|
||||||
|
HelpLauncher.launchUrl(getContext(), url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,6 @@ public class AdapterFragment extends Fragment {
|
|||||||
@Override
|
@Override
|
||||||
public void onActivityCreated(Bundle savedInstanceState) {
|
public void onActivityCreated(Bundle savedInstanceState) {
|
||||||
super.onActivityCreated(savedInstanceState);
|
super.onActivityCreated(savedInstanceState);
|
||||||
callbacks.receiveAbsListView((AbsListView) getView().findViewById(R.id.fragmentView));
|
callbacks.receiveAbsListView(getView().findViewById(R.id.fragmentView));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package com.limelight.ui;
|
package com.limelight.ui;
|
||||||
|
|
||||||
public interface GameGestures {
|
public interface GameGestures {
|
||||||
void showKeyboard();
|
void toggleKeyboard();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ public class StreamView extends SurfaceView {
|
|||||||
super(context, attrs, defStyleAttr);
|
super(context, attrs, defStyleAttr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@TargetApi(21)
|
|
||||||
public StreamView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
public StreamView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||||
super(context, attrs, defStyleAttr, defStyleRes);
|
super(context, attrs, defStyleAttr, defStyleRes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,41 +3,22 @@ package com.limelight.utils;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.content.pm.ResolveInfo;
|
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
|
||||||
import com.limelight.HelpActivity;
|
import com.limelight.HelpActivity;
|
||||||
|
|
||||||
public class HelpLauncher {
|
public class HelpLauncher {
|
||||||
|
public static void launchUrl(Context context, String url) {
|
||||||
private static boolean isKnownBrowser(Context context, Intent i) {
|
|
||||||
ResolveInfo resolvedActivity = context.getPackageManager().resolveActivity(i, PackageManager.MATCH_DEFAULT_ONLY);
|
|
||||||
if (resolvedActivity == null) {
|
|
||||||
// No browser
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String name = resolvedActivity.activityInfo.name;
|
|
||||||
if (name == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
name = name.toLowerCase();
|
|
||||||
return name.contains("chrome") || name.contains("firefox");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void launchUrl(Context context, String url) {
|
|
||||||
// Try to launch the default browser
|
// Try to launch the default browser
|
||||||
try {
|
try {
|
||||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||||
i.setData(Uri.parse(url));
|
i.setData(Uri.parse(url));
|
||||||
|
|
||||||
// Several Android TV devices will lie and say they do have a browser
|
// Several Android TV devices will lie and say they do have a browser even though the OS
|
||||||
// even though the OS just shows an error dialog if we try to use it. We need to
|
// just shows an error dialog if we try to use it. We used to try to be clever and check
|
||||||
// be a bit more clever on these devices and detect if the browser is a legitimate
|
// the package name of the resolved intent, but it's not worth it anymore with Android 11's
|
||||||
// browser or just a fake error message activity.
|
// package visibility changes. We'll just always use the WebView on Android TV.
|
||||||
if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
|
if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) {
|
||||||
isKnownBrowser(context, i)) {
|
|
||||||
context.startActivity(i);
|
context.startActivity(i);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -63,4 +44,8 @@ public class HelpLauncher {
|
|||||||
public static void launchTroubleshooting(Context context) {
|
public static void launchTroubleshooting(Context context) {
|
||||||
launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting");
|
launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void launchGameStreamEolFaq(Context context) {
|
||||||
|
launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/NVIDIA-GameStream-End-Of-Service-Announcement-FAQ");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class NetHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
else {
|
||||||
NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo();
|
NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo();
|
||||||
if (activeNetworkInfo != null) {
|
if (activeNetworkInfo != null) {
|
||||||
return activeNetworkInfo.getType() == ConnectivityManager.TYPE_VPN;
|
return activeNetworkInfo.getType() == ConnectivityManager.TYPE_VPN;
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import com.limelight.ShortcutTrampoline;
|
|||||||
import com.limelight.binding.PlatformBinding;
|
import com.limelight.binding.PlatformBinding;
|
||||||
import com.limelight.computers.ComputerManagerService;
|
import com.limelight.computers.ComputerManagerService;
|
||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
import com.limelight.nvstream.http.GfeHttpResponseException;
|
import com.limelight.nvstream.http.HostHttpResponseException;
|
||||||
import com.limelight.nvstream.http.NvApp;
|
import com.limelight.nvstream.http.NvApp;
|
||||||
import com.limelight.nvstream.http.NvHTTP;
|
import com.limelight.nvstream.http.NvHTTP;
|
||||||
|
import com.limelight.nvstream.jni.MoonBridge;
|
||||||
|
|
||||||
import org.xmlpull.v1.XmlPullParserException;
|
import org.xmlpull.v1.XmlPullParserException;
|
||||||
|
|
||||||
@@ -23,7 +24,12 @@ import java.net.UnknownHostException;
|
|||||||
import java.security.cert.CertificateEncodingException;
|
import java.security.cert.CertificateEncodingException;
|
||||||
|
|
||||||
public class ServerHelper {
|
public class ServerHelper {
|
||||||
public static String getCurrentAddressFromComputer(ComputerDetails computer) {
|
public static final String CONNECTION_TEST_SERVER = "android.conntest.moonlight-stream.org";
|
||||||
|
|
||||||
|
public static ComputerDetails.AddressTuple getCurrentAddressFromComputer(ComputerDetails computer) throws IOException {
|
||||||
|
if (computer.activeAddress == null) {
|
||||||
|
throw new IOException("No active address for "+computer.name);
|
||||||
|
}
|
||||||
return computer.activeAddress;
|
return computer.activeAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +55,9 @@ public class ServerHelper {
|
|||||||
public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer,
|
public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer,
|
||||||
ComputerManagerService.ComputerManagerBinder managerBinder) {
|
ComputerManagerService.ComputerManagerBinder managerBinder) {
|
||||||
Intent intent = new Intent(parent, Game.class);
|
Intent intent = new Intent(parent, Game.class);
|
||||||
intent.putExtra(Game.EXTRA_HOST, getCurrentAddressFromComputer(computer));
|
intent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address);
|
||||||
|
intent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port);
|
||||||
|
intent.putExtra(Game.EXTRA_HTTPS_PORT, computer.httpsPort);
|
||||||
intent.putExtra(Game.EXTRA_APP_NAME, app.getAppName());
|
intent.putExtra(Game.EXTRA_APP_NAME, app.getAppName());
|
||||||
intent.putExtra(Game.EXTRA_APP_ID, app.getAppId());
|
intent.putExtra(Game.EXTRA_APP_ID, app.getAppId());
|
||||||
intent.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported());
|
intent.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported());
|
||||||
@@ -68,14 +76,45 @@ public class ServerHelper {
|
|||||||
|
|
||||||
public static void doStart(Activity parent, NvApp app, ComputerDetails computer,
|
public static void doStart(Activity parent, NvApp app, ComputerDetails computer,
|
||||||
ComputerManagerService.ComputerManagerBinder managerBinder) {
|
ComputerManagerService.ComputerManagerBinder managerBinder) {
|
||||||
if (computer.state == ComputerDetails.State.OFFLINE ||
|
if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) {
|
||||||
ServerHelper.getCurrentAddressFromComputer(computer) == null) {
|
|
||||||
Toast.makeText(parent, parent.getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show();
|
Toast.makeText(parent, parent.getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
parent.startActivity(createStartIntent(parent, app, computer, managerBinder));
|
parent.startActivity(createStartIntent(parent, app, computer, managerBinder));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void doNetworkTest(final Activity parent) {
|
||||||
|
new Thread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
SpinnerDialog spinnerDialog = SpinnerDialog.displayDialog(parent,
|
||||||
|
parent.getResources().getString(R.string.nettest_title_waiting),
|
||||||
|
parent.getResources().getString(R.string.nettest_text_waiting),
|
||||||
|
false);
|
||||||
|
|
||||||
|
int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL);
|
||||||
|
spinnerDialog.dismiss();
|
||||||
|
|
||||||
|
String dialogSummary;
|
||||||
|
if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) {
|
||||||
|
dialogSummary = parent.getResources().getString(R.string.nettest_text_inconclusive);
|
||||||
|
}
|
||||||
|
else if (ret == 0) {
|
||||||
|
dialogSummary = parent.getResources().getString(R.string.nettest_text_success);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
dialogSummary = parent.getResources().getString(R.string.nettest_text_failure);
|
||||||
|
dialogSummary += MoonBridge.stringifyPortFlags(ret, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog.displayDialog(parent,
|
||||||
|
parent.getResources().getString(R.string.nettest_title_done),
|
||||||
|
dialogSummary,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
public static void doQuit(final Activity parent,
|
public static void doQuit(final Activity parent,
|
||||||
final ComputerDetails computer,
|
final ComputerDetails computer,
|
||||||
final NvApp app,
|
final NvApp app,
|
||||||
@@ -88,14 +127,14 @@ public class ServerHelper {
|
|||||||
NvHTTP httpConn;
|
NvHTTP httpConn;
|
||||||
String message;
|
String message;
|
||||||
try {
|
try {
|
||||||
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort,
|
||||||
managerBinder.getUniqueId(), computer.serverCert, PlatformBinding.getCryptoProvider(parent));
|
managerBinder.getUniqueId(), computer.serverCert, PlatformBinding.getCryptoProvider(parent));
|
||||||
if (httpConn.quitApp()) {
|
if (httpConn.quitApp()) {
|
||||||
message = parent.getResources().getString(R.string.applist_quit_success) + " " + app.getAppName();
|
message = parent.getResources().getString(R.string.applist_quit_success) + " " + app.getAppName();
|
||||||
} else {
|
} else {
|
||||||
message = parent.getResources().getString(R.string.applist_quit_fail) + " " + app.getAppName();
|
message = parent.getResources().getString(R.string.applist_quit_fail) + " " + app.getAppName();
|
||||||
}
|
}
|
||||||
} catch (GfeHttpResponseException e) {
|
} catch (HostHttpResponseException e) {
|
||||||
if (e.getErrorCode() == 599) {
|
if (e.getErrorCode() == 599) {
|
||||||
message = "This session wasn't started by this device," +
|
message = "This session wasn't started by this device," +
|
||||||
" so it cannot be quit. End streaming on the original " +
|
" so it cannot be quit. End streaming on the original " +
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ package com.limelight.utils;
|
|||||||
|
|
||||||
import android.annotation.TargetApi;
|
import android.annotation.TargetApi;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.pm.ShortcutInfo;
|
import android.content.pm.ShortcutInfo;
|
||||||
import android.content.pm.ShortcutManager;
|
import android.content.pm.ShortcutManager;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.drawable.Icon;
|
import android.graphics.drawable.Icon;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
import com.limelight.AppView;
|
|
||||||
import com.limelight.ShortcutTrampoline;
|
|
||||||
import com.limelight.R;
|
import com.limelight.R;
|
||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
import com.limelight.nvstream.http.NvApp;
|
import com.limelight.nvstream.http.NvApp;
|
||||||
@@ -39,7 +36,7 @@ public class ShortcutHelper {
|
|||||||
@TargetApi(Build.VERSION_CODES.N_MR1)
|
@TargetApi(Build.VERSION_CODES.N_MR1)
|
||||||
private void reapShortcutsForDynamicAdd() {
|
private void reapShortcutsForDynamicAdd() {
|
||||||
List<ShortcutInfo> dynamicShortcuts = sm.getDynamicShortcuts();
|
List<ShortcutInfo> dynamicShortcuts = sm.getDynamicShortcuts();
|
||||||
while (dynamicShortcuts.size() >= sm.getMaxShortcutCountPerActivity()) {
|
while (!dynamicShortcuts.isEmpty() && dynamicShortcuts.size() >= sm.getMaxShortcutCountPerActivity()) {
|
||||||
ShortcutInfo maxRankShortcut = dynamicShortcuts.get(0);
|
ShortcutInfo maxRankShortcut = dynamicShortcuts.get(0);
|
||||||
for (ShortcutInfo scut : dynamicShortcuts) {
|
for (ShortcutInfo scut : dynamicShortcuts) {
|
||||||
if (maxRankShortcut.getRank() < scut.getRank()) {
|
if (maxRankShortcut.getRank() < scut.getRank()) {
|
||||||
@@ -118,8 +115,16 @@ public class ShortcutHelper {
|
|||||||
// To avoid a random carousel of shortcuts popping in and out based on polling status,
|
// To avoid a random carousel of shortcuts popping in and out based on polling status,
|
||||||
// we only add shortcuts if it's not at the limit or the user made a conscious action
|
// we only add shortcuts if it's not at the limit or the user made a conscious action
|
||||||
// to interact with this PC.
|
// to interact with this PC.
|
||||||
if (forceAdd || sm.getDynamicShortcuts().size() < sm.getMaxShortcutCountPerActivity()) {
|
|
||||||
|
if (forceAdd) {
|
||||||
|
// This should free an entry for us to add one below
|
||||||
reapShortcutsForDynamicAdd();
|
reapShortcutsForDynamicAdd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// We still need to check the maximum shortcut count even after reaping,
|
||||||
|
// because there's a possibility that it could be zero.
|
||||||
|
if (sm.getDynamicShortcuts().size() < sm.getMaxShortcutCountPerActivity()) {
|
||||||
|
// Add a shortcut if there is room
|
||||||
sm.addDynamicShortcuts(Collections.singletonList(sinfo));
|
sm.addDynamicShortcuts(Collections.singletonList(sinfo));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ public class TvChannelHelper {
|
|||||||
intent.putExtra(TvContract.EXTRA_CHANNEL_ID, getChannelId(computer.uuid));
|
intent.putExtra(TvContract.EXTRA_CHANNEL_ID, getChannelId(computer.uuid));
|
||||||
try {
|
try {
|
||||||
context.startActivityForResult(intent, 0);
|
context.startActivityForResult(intent, 0);
|
||||||
} catch (ActivityNotFoundException e) {
|
} catch (Exception ignored) {
|
||||||
|
// ActivityNotFoundException is the only officially documented
|
||||||
|
// exception that can result from this call. However some buggy
|
||||||
|
// devices throw others.
|
||||||
|
// See https://github.com/moonlight-stream/moonlight-android/issues/1302
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,8 +81,18 @@ public class TvChannelHelper {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri channelUri = context.getContentResolver().insert(
|
Uri channelUri;
|
||||||
TvContract.Channels.CONTENT_URI, builder.toContentValues());
|
|
||||||
|
try {
|
||||||
|
channelUri = context.getContentResolver().insert(
|
||||||
|
TvContract.Channels.CONTENT_URI, builder.toContentValues());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// This can happen on HarmonyOS devices which report to
|
||||||
|
// support Leanback APIs, yet don't implement this URI
|
||||||
|
e.printStackTrace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (channelUri != null) {
|
if (channelUri != null) {
|
||||||
long id = ContentUris.parseId(channelUri);
|
long id = ContentUris.parseId(channelUri);
|
||||||
updateChannelIcon(id);
|
updateChannelIcon(id);
|
||||||
@@ -144,8 +158,15 @@ public class TvChannelHelper {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.getContentResolver().insert(TvContract.PreviewPrograms.CONTENT_URI,
|
try {
|
||||||
builder.toContentValues());
|
context.getContentResolver().insert(TvContract.PreviewPrograms.CONTENT_URI,
|
||||||
|
builder.toContentValues());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// This can happen on HarmonyOS devices which report to
|
||||||
|
// support Leanback APIs, yet don't implement this URI
|
||||||
|
e.printStackTrace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
TvContract.requestChannelBrowsable(context, channelId);
|
TvContract.requestChannelBrowsable(context, channelId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.limelight.utils;
|
|||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
|
import android.app.GameManager;
|
||||||
|
import android.app.GameState;
|
||||||
|
import android.app.LocaleManager;
|
||||||
import android.app.UiModeManager;
|
import android.app.UiModeManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
@@ -9,10 +12,12 @@ import android.content.SharedPreferences;
|
|||||||
import android.content.res.Configuration;
|
import android.content.res.Configuration;
|
||||||
import android.graphics.Insets;
|
import android.graphics.Insets;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.LocaleList;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
|
||||||
|
import com.limelight.Game;
|
||||||
import com.limelight.R;
|
import com.limelight.R;
|
||||||
import com.limelight.nvstream.http.ComputerDetails;
|
import com.limelight.nvstream.http.ComputerDetails;
|
||||||
import com.limelight.preferences.PreferenceConfiguration;
|
import com.limelight.preferences.PreferenceConfiguration;
|
||||||
@@ -24,25 +29,66 @@ public class UiHelper {
|
|||||||
private static final int TV_VERTICAL_PADDING_DP = 15;
|
private static final int TV_VERTICAL_PADDING_DP = 15;
|
||||||
private static final int TV_HORIZONTAL_PADDING_DP = 15;
|
private static final int TV_HORIZONTAL_PADDING_DP = 15;
|
||||||
|
|
||||||
|
private static void setGameModeStatus(Context context, boolean streaming, boolean interruptible) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
GameManager gameManager = context.getSystemService(GameManager.class);
|
||||||
|
|
||||||
|
if (streaming) {
|
||||||
|
gameManager.setGameState(new GameState(false, interruptible ? GameState.MODE_GAMEPLAY_INTERRUPTIBLE : GameState.MODE_GAMEPLAY_UNINTERRUPTIBLE));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
gameManager.setGameState(new GameState(false, GameState.MODE_NONE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void notifyStreamConnecting(Context context) {
|
||||||
|
setGameModeStatus(context, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void notifyStreamConnected(Context context) {
|
||||||
|
setGameModeStatus(context, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void notifyStreamEnteringPiP(Context context) {
|
||||||
|
setGameModeStatus(context, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void notifyStreamExitingPiP(Context context) {
|
||||||
|
setGameModeStatus(context, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void notifyStreamEnded(Context context) {
|
||||||
|
setGameModeStatus(context, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
public static void setLocale(Activity activity)
|
public static void setLocale(Activity activity)
|
||||||
{
|
{
|
||||||
String locale = PreferenceConfiguration.readPreferences(activity).language;
|
String locale = PreferenceConfiguration.readPreferences(activity).language;
|
||||||
if (!locale.equals(PreferenceConfiguration.DEFAULT_LANGUAGE)) {
|
if (!locale.equals(PreferenceConfiguration.DEFAULT_LANGUAGE)) {
|
||||||
Configuration config = new Configuration(activity.getResources().getConfiguration());
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
// On Android 13, migrate this non-default language setting into the OS native API
|
||||||
// Some locales include both language and country which must be separated
|
LocaleManager localeManager = activity.getSystemService(LocaleManager.class);
|
||||||
// before calling the Locale constructor.
|
localeManager.setApplicationLocales(LocaleList.forLanguageTags(locale));
|
||||||
if (locale.contains("-"))
|
PreferenceConfiguration.completeLanguagePreferenceMigration(activity);
|
||||||
{
|
|
||||||
config.locale = new Locale(locale.substring(0, locale.indexOf('-')),
|
|
||||||
locale.substring(locale.indexOf('-') + 1));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
config.locale = new Locale(locale);
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
Configuration config = new Configuration(activity.getResources().getConfiguration());
|
||||||
|
|
||||||
activity.getResources().updateConfiguration(config, activity.getResources().getDisplayMetrics());
|
// Some locales include both language and country which must be separated
|
||||||
|
// before calling the Locale constructor.
|
||||||
|
if (locale.contains("-"))
|
||||||
|
{
|
||||||
|
config.locale = new Locale(locale.substring(0, locale.indexOf('-')),
|
||||||
|
locale.substring(locale.indexOf('-') + 1));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config.locale = new Locale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
activity.getResources().updateConfiguration(config, activity.getResources().getDisplayMetrics());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +114,9 @@ public class UiHelper {
|
|||||||
View rootView = activity.findViewById(android.R.id.content);
|
View rootView = activity.findViewById(android.R.id.content);
|
||||||
UiModeManager modeMgr = (UiModeManager) activity.getSystemService(Context.UI_MODE_SERVICE);
|
UiModeManager modeMgr = (UiModeManager) activity.getSystemService(Context.UI_MODE_SERVICE);
|
||||||
|
|
||||||
|
// Set GameState.MODE_NONE initially for all activities
|
||||||
|
setGameModeStatus(activity, false, false);
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
// Allow this non-streaming activity to layout under notches.
|
// Allow this non-streaming activity to layout under notches.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# Application.mk for Moonlight
|
# Application.mk for Moonlight
|
||||||
|
|
||||||
# Our minimum version is Android 4.1
|
# Our minimum version is Android 5.0
|
||||||
APP_PLATFORM := android-16
|
APP_PLATFORM := android-21
|
||||||
|
|
||||||
|
# We support 16KB pages
|
||||||
|
APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ LOCAL_MODULE := moonlight-core
|
|||||||
LOCAL_SRC_FILES := moonlight-common-c/src/AudioStream.c \
|
LOCAL_SRC_FILES := moonlight-common-c/src/AudioStream.c \
|
||||||
moonlight-common-c/src/ByteBuffer.c \
|
moonlight-common-c/src/ByteBuffer.c \
|
||||||
moonlight-common-c/src/Connection.c \
|
moonlight-common-c/src/Connection.c \
|
||||||
|
moonlight-common-c/src/ConnectionTester.c \
|
||||||
moonlight-common-c/src/ControlStream.c \
|
moonlight-common-c/src/ControlStream.c \
|
||||||
moonlight-common-c/src/FakeCallbacks.c \
|
moonlight-common-c/src/FakeCallbacks.c \
|
||||||
moonlight-common-c/src/InputStream.c \
|
moonlight-common-c/src/InputStream.c \
|
||||||
moonlight-common-c/src/LinkedBlockingQueue.c \
|
moonlight-common-c/src/LinkedBlockingQueue.c \
|
||||||
moonlight-common-c/src/Misc.c \
|
moonlight-common-c/src/Misc.c \
|
||||||
moonlight-common-c/src/Platform.c \
|
moonlight-common-c/src/Platform.c \
|
||||||
|
moonlight-common-c/src/PlatformCrypto.c \
|
||||||
moonlight-common-c/src/PlatformSockets.c \
|
moonlight-common-c/src/PlatformSockets.c \
|
||||||
moonlight-common-c/src/RtpFecQueue.c \
|
moonlight-common-c/src/RtpAudioQueue.c \
|
||||||
moonlight-common-c/src/RtpReorderQueue.c \
|
moonlight-common-c/src/RtpVideoQueue.c \
|
||||||
moonlight-common-c/src/RtspConnection.c \
|
moonlight-common-c/src/RtspConnection.c \
|
||||||
moonlight-common-c/src/RtspParser.c \
|
moonlight-common-c/src/RtspParser.c \
|
||||||
moonlight-common-c/src/SdpGenerator.c \
|
moonlight-common-c/src/SdpGenerator.c \
|
||||||
@@ -38,6 +40,7 @@ LOCAL_SRC_FILES := moonlight-common-c/src/AudioStream.c \
|
|||||||
moonlight-common-c/enet/win32.c \
|
moonlight-common-c/enet/win32.c \
|
||||||
simplejni.c \
|
simplejni.c \
|
||||||
callbacks.c \
|
callbacks.c \
|
||||||
|
minisdl.c \
|
||||||
|
|
||||||
|
|
||||||
LOCAL_C_INCLUDES := $(LOCAL_PATH)/moonlight-common-c/enet/include \
|
LOCAL_C_INCLUDES := $(LOCAL_PATH)/moonlight-common-c/enet/include \
|
||||||
@@ -52,7 +55,11 @@ endif
|
|||||||
|
|
||||||
LOCAL_LDLIBS := -llog
|
LOCAL_LDLIBS := -llog
|
||||||
|
|
||||||
LOCAL_STATIC_LIBRARIES := libopus libssl libcrypto
|
LOCAL_STATIC_LIBRARIES := libopus libssl libcrypto cpufeatures
|
||||||
LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL
|
LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL
|
||||||
|
|
||||||
|
LOCAL_BRANCH_PROTECTION := standard
|
||||||
|
|
||||||
include $(BUILD_SHARED_LIBRARY)
|
include $(BUILD_SHARED_LIBRARY)
|
||||||
|
|
||||||
|
$(call import-module,android/cpufeatures)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Static libraries were built from https://github.com/cgutman/moonlight-mobile-deps using AppVeyor CI
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user