Compare commits
2144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 232b897abc | |||
| efd076bc6c | |||
| cc877480ff | |||
| 363145a284 | |||
| 755571ad33 | |||
| 39edb55721 | |||
| 15aa7ecc2e | |||
| ce9e91153e | |||
| 9ee0a46606 | |||
| 20dc351f4c | |||
| c30c54d562 | |||
| 45ff51c0d2 | |||
| 5b86e99138 | |||
| 0c72910eb7 | |||
| 3b0f485b41 | |||
| 2be2c95212 | |||
| e7aeeb8bd5 | |||
| 73df93f86a | |||
| 9cd4d5e2aa | |||
| c3b81554f4 | |||
| 6f79c52fc5 | |||
| 29bc3e022b | |||
| 7d03203d83 | |||
| 11dde835d1 | |||
| 52c47c288c | |||
| 63072aa8e1 | |||
| 4cca3ac922 | |||
| 604bc1ec11 | |||
| 5d7fbf3195 | |||
| 8c56e6f0d4 | |||
| 2069be7932 | |||
| 9c1c2991a9 | |||
| 81dabf2713 | |||
| 27520cb77e | |||
| f555d3dae0 | |||
| 70f1a2cacb | |||
| 7f15aaa2e5 | |||
| e5726205c4 | |||
| 07fabc0663 | |||
| 800f97ae85 | |||
| 3ee5b284e1 | |||
| c0389f0da9 | |||
| a7a4d7ded5 | |||
| 87cd974b79 | |||
| 7faaac31ff | |||
| 7386eb2a78 | |||
| 49a1524f4f | |||
| c957b8b06b | |||
| a3a6e14d80 | |||
| 7231f5468b | |||
| 4dfb0d7220 | |||
| 2f4f53b048 | |||
| b6e8389544 | |||
| d113878613 | |||
| f7ed7e06db | |||
| 977a1d4a3c | |||
| eefc08db47 | |||
| ab2b1663d3 | |||
| 04b8a718e3 | |||
| 37cf260ba6 | |||
| 8f91fe4cd1 | |||
| 9246ad412f | |||
| 1ccbbdd4fb | |||
| 16cf37994d | |||
| 01e84624c2 | |||
| 939cd7cf70 | |||
| 4b11603035 | |||
| ca18b6b052 | |||
| 3d0d19e561 | |||
| ae463a8735 | |||
| 7e797829ae | |||
| 431ed6bc5d | |||
| e9bb711c42 | |||
| 623bc5c156 | |||
| cfefef4619 | |||
| 4a9a881c1f | |||
| 13a06d585c | |||
| 1c8ad64da0 | |||
| 1d8925de57 | |||
| 0eb7e779b8 | |||
| a4b86eefe2 | |||
| 902a58bc70 | |||
| a34a44f29a | |||
| 454fe80172 | |||
| 81b6a8a311 | |||
| 3011a5bad7 | |||
| dcb7be3acd | |||
| 68a6b510b1 | |||
| dca3e89303 | |||
| bae6fef588 | |||
| 37f65e43a5 | |||
| 8c910101c7 | |||
| 112d9c41eb | |||
| c91d1097f6 | |||
| 105c2c9eef | |||
| b754d2de28 | |||
| f6425c7ec6 | |||
| e690c9b8c8 | |||
| f87cbac77c | |||
| 150bd313cf | |||
| bc90cb894c | |||
| c51a75a681 | |||
| 68aa9bd12d | |||
| 1fb6bf4d70 | |||
| b4df3658f1 | |||
| 4efb4d9b24 | |||
| d61e893731 | |||
| 951e44728e | |||
| 8dcdf73222 | |||
| 44c3b0af57 | |||
| 2b295400ac | |||
| aa8d8e93d2 | |||
| 89be7eac0e | |||
| f3847b932b | |||
| 5e4f37532c | |||
| f3f5ca74a3 | |||
| e50b7076a1 | |||
| 36ab5aa1b6 | |||
| a0a2b299d9 | |||
| 14d354fc29 | |||
| 342515f916 | |||
| 5f5944c237 | |||
| c025432ad6 | |||
| 171a6437fe | |||
| 11b3648fac | |||
| d1fae89d6d | |||
| 5c06848fe9 | |||
| b50e506e58 | |||
| 59fafa163d | |||
| 22d84b5763 | |||
| 6d186892a8 | |||
| 88d6143897 | |||
| b729fba75e | |||
| c0d3f9fa48 | |||
| af5e7a0e33 | |||
| 371d96ea65 | |||
| e9e332ff85 | |||
| e133ac2815 | |||
| 1dba5d147e | |||
| 1616c0b022 | |||
| bcee2cf0e3 | |||
| 3e7ddab0e9 | |||
| 5da0177356 | |||
| 7e21638811 | |||
| db5b7ab867 | |||
| 3bcc1c84bb | |||
| d46053f8d6 | |||
| 00a5fed9e9 | |||
| b6315a715a | |||
| 0da8303468 | |||
| c821c4684f | |||
| 6bae33f822 | |||
| 08d4ab67a6 | |||
| 62203d2f21 | |||
| 4968dcc558 | |||
| 6d66d1371f | |||
| b87ca71103 | |||
| c251cd2e8f | |||
| 593616d2d9 | |||
| a2fc62a4a8 | |||
| fd457c5dea | |||
| 64e1ba500c | |||
| 235a0635be | |||
| 61f8fa7c5a | |||
| 7c3c107381 | |||
| 555477751f | |||
| 9364f43c52 | |||
| 0be3169c2c | |||
| 5199d90505 | |||
| 5b5277bf3f | |||
| ad3614c58e | |||
| 9401ecc9fb | |||
| 1711e5e1a4 | |||
| f28f9bc65f | |||
| 8eb4014f01 | |||
| df0d7952db | |||
| 77d1770063 | |||
| f433bfdc02 | |||
| f75b6f9b80 | |||
| 621df9996d | |||
| 6c29503db9 | |||
| 304a02e2ec | |||
| 7aea7ed8c6 | |||
| e5ab3baa7b | |||
| 41b73f7cd9 | |||
| 38da42caf3 | |||
| 424d71fa13 | |||
| dbc9d78002 | |||
| b7ef8f54b7 | |||
| bea7cab0c3 | |||
| 352b6f7dd9 | |||
| 8665fe364f | |||
| 7d023c8865 | |||
| 503d4b970c | |||
| 6b07072a08 | |||
| 2b02af5d98 | |||
| 613c068523 | |||
| 0b0181f35c | |||
| c873bae3e4 | |||
| 7397a97a9e | |||
| b567db9ab7 | |||
| 3440f54598 | |||
| d533b25b29 | |||
| 72290bd725 | |||
| fdd4c0bbe1 | |||
| 0ac83e1cf7 | |||
| e27129fc48 | |||
| d54fdc9f5f | |||
| dc984e8679 | |||
| ee46906376 | |||
| 1d76536e31 | |||
| dc97adc7a1 | |||
| a1c659b7b8 | |||
| 9997f164b9 | |||
| 2f7ac67cb0 | |||
| 27f0fd63b3 | |||
| 9f56fdfbb9 | |||
| 83b66b19de | |||
| 9a4f85e752 | |||
| d00824d49c | |||
| ba0171221c | |||
| 68040394fb | |||
| 6fa1c35521 | |||
| 7a3fbd8dae | |||
| 329ee1a0bc | |||
| 11908e07bf | |||
| fd53122cb3 | |||
| d9c0830198 | |||
| d0aafb3814 | |||
| 40a3cc2ecb | |||
| 4469013bb5 | |||
| 78393932d0 | |||
| dbc2491151 | |||
| 936834e396 | |||
| 01eb7a2b64 | |||
| 252285e4f7 | |||
| df7333b8d0 | |||
| cf98ec2c41 | |||
| 0afda10bcb | |||
| 754773420f | |||
| 71aadfa2f5 | |||
| f7bfa63145 | |||
| 6574a0aab2 | |||
| 5d4988969e | |||
| 5121eb1852 | |||
| 004aeef2a7 | |||
| aa65a0312a | |||
| a1b58ab2fc | |||
| d32c0e32d0 | |||
| 9cd71e2855 | |||
| 1308a4ed80 | |||
| deb78e1c64 | |||
| 9aec6b1d31 | |||
| 97702b8861 | |||
| 832e7197c5 | |||
| 26b992726c | |||
| 1cb3588841 | |||
| b461d546d6 | |||
| b7810d6eb6 | |||
| 6fb3a8e57d | |||
| b521c784bc | |||
| 8e1641af5f | |||
| c0aac01d33 | |||
| 4f8b0adcbb | |||
| d7bdfb4db9 | |||
| 393a4c9c8a | |||
| 99b53f9a6a | |||
| 8da563b280 | |||
| 5cca5cd352 | |||
| d48e964d05 | |||
| ad94978f98 | |||
| d5b950e5cf | |||
| c46b9acf6b | |||
| 1d65baa981 | |||
| d8e322bac9 | |||
| 44871626cf | |||
| f661522b5d | |||
| 8b7287a5d6 | |||
| a454b0ab78 | |||
| c4ae049091 | |||
| 75bf84d0d9 | |||
| c248994ed4 | |||
| 0e4e3d80d2 | |||
| 59b2449cdc | |||
| a7a34ec629 | |||
| 8d469c5d0a | |||
| e6979d50b5 | |||
| 640255efb2 | |||
| 6e25b135a3 | |||
| 04e093a2c2 | |||
| 1bffa6bf41 | |||
| 69b175573d | |||
| 0eaff9d328 | |||
| 44905ed774 | |||
| 813f2edd95 | |||
| 337d753a33 | |||
| 1137c74f76 | |||
| 0c1451f757 | |||
| 5ab9ea48fd | |||
| ffcb623040 | |||
| bfe6929642 | |||
| 50d45011a8 | |||
| 2f7087d6d3 | |||
| 92b71588d0 | |||
| 4f3d018764 | |||
| a22e33eeb9 | |||
| 10e0a262f7 | |||
| 6a939e7495 | |||
| 422e703c2f | |||
| f8ba7cf190 | |||
| 27191da45e | |||
| d1e135db4d | |||
| 61a17afe69 | |||
| 47fd691884 | |||
| 0d171c6b28 | |||
| f0c69d08b8 | |||
| 629bf5766d | |||
| 233bceeece | |||
| 24926c75f1 | |||
| 6660ea7d91 | |||
| 4864b2ca45 | |||
| 92097b318d | |||
| 997898c99d | |||
| 1174e03885 | |||
| ff0f54d541 | |||
| a83f6f2ef7 | |||
| 814964a100 | |||
| 7e154292a9 | |||
| 0220dd921a | |||
| 0f9cba1053 | |||
| 05e4792d6f | |||
| c9b5c00756 | |||
| a4e134589d | |||
| cd80a94f28 | |||
| 57c645a291 | |||
| 6f35b991b7 | |||
| 0cba200207 | |||
| 81582d7343 | |||
| 04e561fd54 | |||
| a7023f52aa | |||
| 5efbb5229d | |||
| 541e43eb18 | |||
| 7e679ff4c6 | |||
| 486b4b4c4c | |||
| 752b204be8 | |||
| 7d76bf7868 | |||
| 564e7c71a6 | |||
| db49077b9b | |||
| 67f01fbdca | |||
| 16b845ab84 | |||
| 5c175fecf6 | |||
| 773976b265 | |||
| 80070bbdbe | |||
| 4c8d433b6c | |||
| 404f096d11 | |||
| d2ac927cec | |||
| 5e3d59d3d7 | |||
| 9cd2ce1309 | |||
| 9ed49730d4 | |||
| 39ebb48f58 | |||
| 1c29c70fba | |||
| 6993051529 | |||
| 4930087c4d | |||
| 795f0a013b | |||
| 213414778e | |||
| 7eac0ccaf8 | |||
| 02b74fbbc5 | |||
| 6adc9dcb2d | |||
| be620908f9 | |||
| efcfcf88db | |||
| e4edfdb043 | |||
| 3b5028d1a4 | |||
| bc8c45bd59 | |||
| 63eb346a70 | |||
| 68028242b4 | |||
| 27ad691d23 | |||
| 747e920061 | |||
| bae3b4a6e8 | |||
| 8d09f56a0e | |||
| 113a0e2c45 | |||
| 977215a098 | |||
| a7e65b47f9 | |||
| 7126055ad6 | |||
| 3de9765eaa | |||
| d4072eb295 | |||
| cac2bdbb81 | |||
| 66f0aee3f8 | |||
| b690dc5474 | |||
| 514e0ca2c9 | |||
| c9cf485025 | |||
| c2fbe6ad91 | |||
| cf07c02398 | |||
| 42dc928ad5 | |||
| 11597f0aa7 | |||
| cdcd4d48f2 | |||
| a9af4e54a9 | |||
| 7eac609219 | |||
| fa761debc4 | |||
| 62e175f069 | |||
| d7d8c40565 | |||
| 64de13ab50 | |||
| 2f02939638 | |||
| 1d7c8697e9 | |||
| 16c4b2532d | |||
| 7dea322bbd | |||
| a64db9d86f | |||
| 349ecb16ab | |||
| a3867735c1 | |||
| 5b087e9f70 | |||
| 14d9f77e4e | |||
| eed18223eb | |||
| 30d4d2a918 | |||
| 40c3db0214 | |||
| 30f666c70e | |||
| 209fead0e8 | |||
| 5c6889bf6d | |||
| 7d24900756 | |||
| f7c5039912 | |||
| 79a75b9d19 | |||
| 3c37c89db8 | |||
| 29b64992bd | |||
| c9b14540f2 | |||
| 6995a27126 | |||
| 546843a26c | |||
| d03d260535 | |||
| 6946e3c7a2 | |||
| b79d328961 | |||
| c313797d93 | |||
| c8cb8e1346 | |||
| f61540c099 | |||
| 6a9f8da14e | |||
| 6a6bd9fb0b | |||
| ff9260a0fd | |||
| 62bedb1609 | |||
| a519723d44 | |||
| c2a16a9b4a | |||
| 36191781ed | |||
| 61b6a49669 | |||
| e97845e46e | |||
| 6bba68207d | |||
| 0e17cccc06 | |||
| 918e922e40 | |||
| a08854ddfd | |||
| eb6f15c2b7 | |||
| 2cd9e31684 | |||
| 791d6624e2 | |||
| af41021271 | |||
| d726d939f4 | |||
| 748085e7bb | |||
| b64c84a5c3 | |||
| d57d19174b | |||
| efebe1828a | |||
| 06007e0597 | |||
| 42a502eff1 | |||
| 3a868045d7 | |||
| e0a7ff1880 | |||
| 88d43bbd40 | |||
| 30ff319b13 | |||
| 9a0f48b799 | |||
| b52c8a1a8f | |||
| 3fde115670 | |||
| b6f4d8ff1e | |||
| 46d72b912c | |||
| 722f397819 | |||
| a7d85a7dd5 | |||
| 9b238ab6c3 | |||
| f82ee97c05 | |||
| 8f169b976b | |||
| 35fb96f9f4 | |||
| 37371906d5 | |||
| 53452d22c0 | |||
| 9d4e9631bc | |||
| 83a9539f4b | |||
| b214fe5301 | |||
| 57779b4e89 | |||
| 547932f8b2 | |||
| 762fa0fe2f | |||
| 9cedc57df2 | |||
| ba81f8096a | |||
| c4fa654166 | |||
| 8ac440b68b | |||
| 165386b941 | |||
| 3a7398f321 | |||
| ebb1d0dfa2 | |||
| 1ca1ed5d20 | |||
| b416bafb78 | |||
| 3a301b74a6 | |||
| 71d463f063 | |||
| 1fae816223 | |||
| 989d6fc169 | |||
| 381509b3a6 | |||
| d8ae40376e | |||
| 4ea93f5e68 | |||
| cd84c8f30e | |||
| 8d4cdca7c3 | |||
| c0239c36fd | |||
| 9d9f729e42 | |||
| 6c5fe18b6e | |||
| 1994bf6522 | |||
| 31381e5664 | |||
| fac1b1d7e5 | |||
| 40c406051c | |||
| 8bac873e67 | |||
| a170e1efd7 | |||
| 17bffa8d78 | |||
| 289222749b | |||
| 81d84600d4 | |||
| 0b15fd582d | |||
| cbe4a1cde6 | |||
| 5942545b9c | |||
| 89ef16c02e | |||
| 58b6ed8d00 | |||
| 7d01e1a7a4 | |||
| ab769a1606 | |||
| 3ac9abbab1 | |||
| 288efd0726 | |||
| d2d0ed65d6 | |||
| e697ed72db | |||
| b657c746be | |||
| 947f8db2d5 | |||
| 15857efd36 | |||
| 3fd0f20e10 | |||
| a2e64fd7df | |||
| a620dc7d0c | |||
| 9d7a28e408 | |||
| 3244344fc7 | |||
| 75057f2d39 | |||
| bbec3402d9 | |||
| dcf4dac8dd | |||
| d98f484aaf | |||
| 0218a9ce14 | |||
| 0ec6dcd67e | |||
| 88f9b68db7 | |||
| 3c2fd32d1e | |||
| 6557cba307 | |||
| ae6f797436 | |||
| 3442a64f4d | |||
| 035a62856d | |||
| 37ddccde0c | |||
| ffc59c6bd6 | |||
| 636c1ceb26 | |||
| 88f84a0c12 | |||
| 03ecf3e5ac | |||
| 617c8582b4 | |||
| ef3b28295b | |||
| 37cde22a55 | |||
| 3bcd2ee068 | |||
| d4ff58b3ad | |||
| c797318ece | |||
| 82387d23f8 | |||
| 772835689d | |||
| 766e629be5 | |||
| b93aa42c0c | |||
| 36f132942f | |||
| 7dcc689014 | |||
| e4c251e7ee | |||
| fb54bd5c78 | |||
| 8d4c86e113 | |||
| 90981a6643 | |||
| de05a5b446 | |||
| f644436aeb | |||
| 7a8166ec09 | |||
| 7fafb8e0ff | |||
| fbcbe09255 | |||
| e336a4446a | |||
| 6eed8408fc | |||
| ffb35b2cdd | |||
| 2d0af6281c | |||
| 472a7f6c8a | |||
| cd06559c66 | |||
| d833933aaa | |||
| dc3495d59b | |||
| e3a2e40043 | |||
| 31e1fb743e | |||
| bc59f11096 | |||
| 6d97775aa9 | |||
| 4b9ee92434 | |||
| 3fff34e08a | |||
| 15e856dccb | |||
| 07d04171c3 | |||
| 42bd93cb3a | |||
| 756ceaff1a | |||
| 7d289f1134 | |||
| 214461e123 | |||
| b0144a3256 | |||
| 3171256c6e | |||
| 5c69f6716c | |||
| 6264781539 | |||
| d3be670974 | |||
| 0225f534d0 | |||
| 284a31737e | |||
| b37a2dea57 | |||
| 5c865e7f36 | |||
| 04d9aea8c8 | |||
| b6f52db9c3 | |||
| 99d2e40683 | |||
| 02c4ed2724 | |||
| 5f4aab8f94 | |||
| 7b41b1158e | |||
| ec65901003 | |||
| 915acee88d | |||
| 300d444f71 | |||
| f37ab40c2f | |||
| 16e285d926 | |||
| f2d122a275 | |||
| bfa5a6349e | |||
| a56689aea3 | |||
| 3a5ba820cb | |||
| ec69fef36f | |||
| ff38074f55 | |||
| 85d0ce0c40 | |||
| 777129ca90 | |||
| 06156c4d68 | |||
| 1c725b9dac | |||
| f761ee52db | |||
| 05e8cfcc0a | |||
| 912925ef2c | |||
| 4deb881ec8 | |||
| f55d6308ce | |||
| 44a3a141c0 | |||
| 37b5ba004c | |||
| d0da5d3702 | |||
| b774b47213 | |||
| 42668b5699 | |||
| 74dc00445e | |||
| 3b4563d5ea | |||
| 38669817b4 | |||
| 9444430830 | |||
| 8f1d3ae04e | |||
| 74ed95871b | |||
| cc5d67616c | |||
| eed7f09e6f | |||
| e3c1d23744 | |||
| c4b1200b43 | |||
| e30088e53b | |||
| dff09f33a3 | |||
| 1f6b1dc2fe | |||
| 3f118dae93 | |||
| a989bdde80 | |||
| 91a30ff6fe | |||
| 5102669b06 | |||
| 2e2f09be00 | |||
| e5d9da447c | |||
| c402103fe3 | |||
| 5e5df8abc8 | |||
| d125eb7b16 | |||
| a116858493 | |||
| 5f3b333e98 | |||
| 80a37855c7 | |||
| 5db1ec8ec0 | |||
| ba5c026bff | |||
| 8911c58e50 | |||
| 780a64694d | |||
| 66536aa755 | |||
| 3c5ea9c8c3 | |||
| 40d1436ce3 | |||
| a53444148e | |||
| dbb02acd37 | |||
| d237ceb1df | |||
| 20c4eac4ef | |||
| b9f1142af7 | |||
| 38a6a2b74a | |||
| fd2421618a | |||
| 79a9ea7179 | |||
| 1f504288cb | |||
| 0543420624 | |||
| 34a11c9262 | |||
| 84a9845c1d | |||
| 5b05220008 | |||
| b2bd7257e1 | |||
| 6580eb8ea4 | |||
| 46a998c113 | |||
| 8584bf1910 | |||
| 60cd951774 | |||
| e7f92d3667 | |||
| d4f8d8f689 | |||
| 608a0ebb5b | |||
| f01a15d182 | |||
| 0268b4f958 | |||
| d71cf0eb98 | |||
| 10ab40f823 | |||
| 427edfa021 | |||
| 6f18831d5c | |||
| fe71b1be20 | |||
| a3db09f422 | |||
| d185a05b1d | |||
| 78e575504a | |||
| 0a0be19b69 | |||
| 0792157e9d | |||
| cdd0ecf0b7 | |||
| 1ac721a35b | |||
| e49b1c92a2 | |||
| 0ba0d37a37 | |||
| db4295bf83 | |||
| 824c37f9d5 | |||
| acf4426952 | |||
| 673a115b52 | |||
| e8c50342ab | |||
| 598995de3b | |||
| 01cf0cc649 | |||
| fa560f462f | |||
| f6e40118a9 | |||
| fe7148dbd4 | |||
| 60de065836 | |||
| 6f82f82abb | |||
| 42f18cb4ac | |||
| 1bbd0054c2 | |||
| bedf472e9e | |||
| acdde37a3a | |||
| f4abc66eeb | |||
| ad40e12167 | |||
| 164e6f83d8 | |||
| 1b3322b5ee | |||
| 6340ec6c6d | |||
| babd92c8c0 | |||
| 0074848a4e | |||
| 7f1fe5f520 | |||
| 01458770d2 | |||
| 8d05f044f5 | |||
| f5680b59a5 | |||
| 8c09154183 | |||
| 0ecf86c7ed | |||
| 6789e8d497 | |||
| 7d0160d556 | |||
| f6a0990432 | |||
| 5d6094df97 | |||
| d98d4aeda2 | |||
| 852dcf5a2d | |||
| c8339d5eae | |||
| 82e5aa122d | |||
| 07e4991c56 | |||
| 4eb62e6c5f | |||
| fe237d1da3 | |||
| e199fcd2d9 | |||
| d7c6f63592 | |||
| 4b9c6b149a | |||
| bf82556783 | |||
| f282e84174 | |||
| d1e41e41a1 | |||
| ed1a56dc68 | |||
| 96dfe25a14 | |||
| f76d78607a | |||
| a96f688bb2 | |||
| 90a1e68c68 | |||
| b287606106 | |||
| a413185085 | |||
| aa1b283570 | |||
| f07c886711 | |||
| e66b1ebec9 | |||
| d06912e81a | |||
| 08bcd97594 | |||
| af04831fb0 | |||
| 49e51f5f6f | |||
| 8f3eecd980 | |||
| 4223a7fd30 | |||
| 6edd0ab540 | |||
| ce7146175a | |||
| 3176a85f35 | |||
| ad1c11bba5 | |||
| ac640a6842 | |||
| 8962497a8c | |||
| 636c20d67b | |||
| 5d90950591 | |||
| 7651ce5e84 | |||
| 83141d3f91 | |||
| 55f2e89bbe | |||
| b3a1938c1d | |||
| 0ce1e1be27 | |||
| 3558655b72 | |||
| 470680d463 | |||
| 44cbf8adc1 | |||
| 686490ba70 | |||
| d0ecde1e16 | |||
| 63e2fd447d | |||
| 9417908848 | |||
| 93b0073467 | |||
| 1434be262c | |||
| 75aabd6471 | |||
| bafa2addd3 | |||
| 32b787e77c | |||
| 43b58b7a5e | |||
| 9ae1fe2696 | |||
| 6d0f34e2c4 | |||
| f7d91b5107 | |||
| a3c95480d8 | |||
| 864bcadcb2 | |||
| ae852eb911 | |||
| 732311c2a4 | |||
| 203fcd82e7 | |||
| 043c9a978e | |||
| 36b248be4b | |||
| 67469103d4 | |||
| 9e413000a5 | |||
| 8e247ad9a6 | |||
| bedcbfbb7e | |||
| a2de98c91a | |||
| 73e4970a43 | |||
| ac8b7ae960 | |||
| c62986e7b1 | |||
| 81d1e615bf | |||
| a3d5e955aa | |||
| 244fae07ab | |||
| 04e77e557b | |||
| a748b54041 | |||
| e7d96f0ac2 | |||
| 4555b3c74c | |||
| f77673a5c8 | |||
| 23ebc4d927 | |||
| 8c13186757 | |||
| feafc4ef3c | |||
| 92b86674b9 | |||
| f94d224395 | |||
| 822f498646 | |||
| 5c03295478 | |||
| dc3a923041 | |||
| eccba807bc | |||
| 35fa8f5bcc | |||
| 0380910588 | |||
| e85bb4372e | |||
| 2c345cd6c2 | |||
| b5c96cbb53 | |||
| b21ee5ca31 | |||
| 9c7bff6c75 | |||
| 3d470d9aed | |||
| b2a36c2c73 | |||
| 7978687bfc | |||
| f612ec80e2 | |||
| 7df1a39fcb | |||
| 4566c1855b | |||
| a539ac62ec | |||
| fa52e5edc2 | |||
| 3ca681f050 | |||
| 8086c3d46b | |||
| 928fca843f | |||
| 25d74785d0 | |||
| e12a8e7946 | |||
| 195bf8ed55 | |||
| b14f2ce219 | |||
| d31be3d64e | |||
| 0704f2aaf6 | |||
| 832e52ac74 | |||
| f5444551b2 | |||
| 3143797b55 | |||
| cc9b1aeaab | |||
| 3d177e97e4 | |||
| 6c3aaedc83 | |||
| bf84ebef6d | |||
| 8991b29329 | |||
| fa84575be5 | |||
| 0432d5725b | |||
| 8e7b144339 | |||
| fc629db653 | |||
| d5863e1bef | |||
| c2c3a6b37c | |||
| e701699dea | |||
| 17179bd027 | |||
| b2f210700d | |||
| 52678cfe35 | |||
| f0e85c4c53 | |||
| 92f8425ace | |||
| 6ad001e8be | |||
| b6e4d5528b | |||
| 0f0b83badc | |||
| 453fbb5f58 | |||
| e7dc3a4c11 | |||
| d68b2382cf | |||
| 1b5330323c | |||
| 8aba4888e1 | |||
| 1c3b9a3859 | |||
| e8f04f5a3b | |||
| 56b814e877 | |||
| 628ccd39d6 | |||
| 59db3f9b62 | |||
| 416f922b56 | |||
| b52a86e6cc | |||
| e523b5069e | |||
| e8ae8d9807 | |||
| 64e56a861d | |||
| c1bcd09c9b | |||
| 574258804f | |||
| 21ea3d8a2b | |||
| 6de4288a85 | |||
| 61f89a2d4c | |||
| a107b5e652 | |||
| ba398e4073 | |||
| b02db2c182 | |||
| f8a04cda7a | |||
| 226e8edefc | |||
| a14a4a8d60 | |||
| 9b90b30a1f | |||
| 2ed245b25a | |||
| 4b769839d0 | |||
| 9caf3b37ac | |||
| e6965605c9 | |||
| 5b355a3e73 | |||
| 239dd1d5a1 | |||
| 37509cce9b | |||
| 227c71549b | |||
| 92d534a9c3 | |||
| 2d08f568e9 | |||
| a10d8334f3 | |||
| f88c9904fb | |||
| 0fc61e52dd | |||
| 5e44c33bb6 | |||
| df3655e958 | |||
| fe43e13145 | |||
| acd3aad8d9 | |||
| 811b4b4f22 | |||
| 7db3b9f401 | |||
| a5a099cf43 | |||
| ba605643bb | |||
| a9f7b1aeab | |||
| 4f53cfcb20 | |||
| 96e98c1abb | |||
| 5de6f6ae2b | |||
| 0685722773 | |||
| 29df3b2859 | |||
| fc6f859ced | |||
| 6b21a5416f | |||
| 74e7c8bbf1 | |||
| 757075b16a | |||
| e8903c4d48 | |||
| 98262d16ee | |||
| 339506cf10 | |||
| 63bd5df09b | |||
| 32af2d0831 | |||
| 242b03d4b5 | |||
| 87a62666ac | |||
| 2dcf5486da | |||
| 60d3d8b3ae | |||
| e9141d65fe | |||
| aae591daec | |||
| a5ca8a7472 | |||
| 36f8cc02cb | |||
| 55b9645651 | |||
| d30ecbed5b | |||
| 0bbd27f04c | |||
| ffd70986b3 | |||
| 3c53fb7403 | |||
| 7a81950819 | |||
| 74f212c702 | |||
| 36be943854 | |||
| 26a4fc75a5 | |||
| a5ec5fc265 | |||
| 541ac44be4 | |||
| 117b555fcd | |||
| a10cd04441 | |||
| b5e89e47b6 | |||
| 53dccbde2a | |||
| 439afd15fa | |||
| 8d2bfecb10 | |||
| 7d15c34ed2 | |||
| 56625dfe4b | |||
| 2eab5a3b7b | |||
| f9e811862a | |||
| 25ccc3d0e1 | |||
| 8853bf0670 | |||
| 71fa3a824b | |||
| 56fd50834c | |||
| 48ba812cf6 | |||
| 019dc6d45f | |||
| 4ef1b8dc4c | |||
| cbcb784a79 | |||
| c0d64058fd | |||
| 3c11ff63a7 | |||
| 39fa0258ad | |||
| d0dd5bfa8c | |||
| b948c47618 | |||
| 18cae8ac53 | |||
| 537a50bee5 | |||
| 1a58b228a0 | |||
| 0576231dfc | |||
| 6ad35a83dd | |||
| 33d4dfc745 | |||
| 248a135f86 | |||
| f3bf63a668 | |||
| 2dbb7395a4 | |||
| 7c1eb80d62 | |||
| f2bf093691 | |||
| 2f002bfa4a | |||
| 4a19038d54 | |||
| 15fb3dd92c | |||
| e0982d3961 | |||
| 246fb69050 | |||
| a907dd0084 | |||
| fe58361724 | |||
| 7fb2f15f54 | |||
| f93dbb4116 | |||
| a0f93a2dc3 | |||
| bc34fe3a9f | |||
| bbe49491c1 | |||
| d5ccb80f26 | |||
| 82390ec9b9 | |||
| 34ef95926e | |||
| faa0cba39d | |||
| 8b395bb29f | |||
| 50fd15379a | |||
| ed479f1155 | |||
| 04db9ba714 | |||
| 31d7f237eb | |||
| 6a973e3248 | |||
| 96d9e4977b | |||
| ef8c49f135 | |||
| 5a3897f22a | |||
| ceef00b79a | |||
| a8c460e715 | |||
| 94ee24ea11 | |||
| 1a201f2e94 | |||
| e0c6d41d4b | |||
| d75e42e23d | |||
| 44a0ae86d2 | |||
| b191425112 | |||
| c06a4ab76d | |||
| b3042312f6 | |||
| 5fd105c9a9 | |||
| 06822ad385 | |||
| 3be52280ba | |||
| 306c2d143b | |||
| 5142f978cf | |||
| 667ffd4dfd | |||
| 17626f1853 | |||
| a71a3e22e6 | |||
| da4fab2f3e | |||
| 1db84efb68 | |||
| c1c3af3c66 | |||
| 5c79567a2c | |||
| 0f5fd9af62 | |||
| 99643537d1 | |||
| 4622b9f202 | |||
| 47650386e0 | |||
| 47ea158c4c | |||
| aa3fc34646 | |||
| 92f5f1ac71 | |||
| d9cb5eacf8 | |||
| 5718c47be7 | |||
| eb739f73c7 | |||
| 20a646106b | |||
| 0dc14517cd | |||
| 04713c007b | |||
| 1cac7660b8 | |||
| edb286f9af | |||
| fb15ff99ca | |||
| a455e75e37 | |||
| 2b452e51f9 | |||
| 9d2b6f8854 | |||
| 3be10a1b59 | |||
| 01950c25a8 | |||
| 7ad1ebd0e8 | |||
| ee01a8b5a0 | |||
| 23c54f6813 | |||
| ceef4510fb | |||
| 042a6b943e | |||
| e114b73654 | |||
| da0a505978 | |||
| cb6d4a385c | |||
| 2806aee0fc | |||
| 52736f5162 | |||
| 6d45ad7fe8 | |||
| 2fc53644bc | |||
| b33eaec493 | |||
| 63d6f3ac78 | |||
| fd4caac013 | |||
| ada875cdb0 | |||
| 49ddfa573d | |||
| b58ac367ee | |||
| cf62b4ed95 | |||
| b05c62e141 | |||
| 095556106c | |||
| 5cdd72a45c | |||
| 5d84f8af43 | |||
| d9483d9214 | |||
| 250475830f | |||
| b8a0a823e0 | |||
| 6a54d669a3 | |||
| 62559c4e66 | |||
| e04ecaaf7a | |||
| fa4706c95f | |||
| 7067c0e02e | |||
| d7d90e8e49 | |||
| cc71ce6180 | |||
| f409a3583c | |||
| ac7504e017 | |||
| 345bd3f7c1 | |||
| 2e2960ec69 | |||
| e93b103d1e | |||
| 22977a4c5b | |||
| 7da5d5322b | |||
| 49e2c40ba4 | |||
| 8041a004c2 | |||
| db62d78e04 | |||
| bd79318b1e | |||
| 2736bd9165 | |||
| b6bd48584f | |||
| 7b4f3c975a | |||
| b165fadc55 | |||
| 274e0d0557 | |||
| 7594e51a18 | |||
| bf22819b53 | |||
| 3dea4b15e0 | |||
| 5836b3292b | |||
| a8fd49a234 | |||
| 006ad72eb2 | |||
| dc254e1ee5 | |||
| 4d420b29cb | |||
| 0b9e7aa05b | |||
| b0d31a4d35 | |||
| 24155feea4 | |||
| 8c663cc84a | |||
| db0a4e35c6 | |||
| 68ef98d346 | |||
| 8d1417c636 | |||
| f23bb9fac1 | |||
| d20dde0b6d | |||
| f76b30d109 | |||
| ee1a047cde | |||
| b91ab53219 | |||
| 6eeb7ae5b2 | |||
| 0436179020 | |||
| c92cae51c8 | |||
| 57da68c0e2 | |||
| 4c533fedfd | |||
| f8ab7b8e13 | |||
| 46c5eaf0e1 | |||
| e7e73aa1d2 | |||
| 394221f3df | |||
| 7d2647f830 | |||
| 563c90a8c4 | |||
| 0e0352fdd6 | |||
| d6a8db97d8 | |||
| 05f8fa21de | |||
| ab8779086b | |||
| ed8305b199 | |||
| 1def825c7f | |||
| 3c9b5d3b17 | |||
| 3c2dd88fd3 | |||
| 0e21d5e166 | |||
| 8c221bd786 | |||
| 3b1fcdfb10 | |||
| 9bb91e1085 | |||
| 98bee122fe | |||
| 67dc2ef9ab | |||
| 6aaa9a83a6 | |||
| 2eaea8ce7c | |||
| f5ded03b9b | |||
| f509a4b3ab | |||
| 6459579f15 | |||
| 5112179fca | |||
| 3f46485382 | |||
| b640564689 | |||
| 763f8938b3 | |||
| 4c67631ea5 | |||
| 920154b4b6 | |||
| d8c7d10ed6 | |||
| adcffa62d8 | |||
| 2c5e6c0788 | |||
| a7d4a04ac2 | |||
| d199c1b6c4 | |||
| 92f24d20db | |||
| 0dd43df7aa | |||
| 1675586a29 | |||
| a1e511b19a | |||
| 260d716eb8 | |||
| 5606ed1308 | |||
| a301575dd7 | |||
| e89e803d54 | |||
| 4486a126ad | |||
| d740e7a521 | |||
| cb8eab443c | |||
| fe3b649fe9 | |||
| 51c85a1b10 | |||
| 7223efb9f8 | |||
| c3296cce3d | |||
| 74ea87676e | |||
| fc1c26b5d7 | |||
| df59c99f80 | |||
| 5ef20aba21 | |||
| 54eaee3f79 | |||
| 4c82da1f5c | |||
| 080dc01c21 | |||
| f09fbf4ba6 | |||
| 8a465edad9 | |||
| 9d1510f14d | |||
| 62ea92335d | |||
| 9b9020b512 | |||
| d1e2822b92 | |||
| 533cb747df | |||
| 33a0f9c97f | |||
| ef9a442718 | |||
| b9ac48532f | |||
| ad10413714 | |||
| 886ef425e6 | |||
| c9014da186 | |||
| fbd61d2a21 | |||
| c025f9f02b | |||
| b737acedb0 | |||
| f15bfe3038 | |||
| 8938f51292 | |||
| 4b92b8f714 | |||
| 5f13b9bca4 | |||
| 2f219aac6f | |||
| 1d9efb30e2 | |||
| ed7be00881 | |||
| a6003f6bff | |||
| 4619045375 | |||
| 469dcab5c7 | |||
| e61b8f1b34 | |||
| 79b6ec839a | |||
| fd12e30c53 | |||
| 87a9ca4318 | |||
| 3f64411174 | |||
| 57b0da1a3a | |||
| fd1cb52f5f | |||
| 7d3e74a67f | |||
| d704e322df | |||
| f598153818 | |||
| f395a0c170 | |||
| 654b33d27f | |||
| 6c12da96c9 | |||
| 1a6f639b81 | |||
| 59a00a38c9 | |||
| 2beee168e3 | |||
| a92bbc7e5a | |||
| fbc921dd07 | |||
| 59c6c3d777 | |||
| e7ab61c8d0 | |||
| 5175e68b99 | |||
| 64aa01b2cf | |||
| 7023760782 | |||
| 63964ba6a7 | |||
| 932ce435b5 | |||
| af384d88f7 | |||
| 792846ddad | |||
| 1187d9c78c | |||
| e82683b0f4 | |||
| 4b2299ed02 | |||
| 37db9ab072 | |||
| 4a4f89a992 | |||
| d3a7bba666 | |||
| 8bd6582d07 | |||
| 875089305b | |||
| c19ff71c9a | |||
| 36c320a584 | |||
| fb40060560 | |||
| a4f4887647 | |||
| 316b8c56f1 | |||
| f1d7f556fd | |||
| 1e70e1d329 | |||
| 2cf3855d35 | |||
| e02a009635 | |||
| bd6ff35603 | |||
| 1cb7727dc7 | |||
| 0c73e3d0ae | |||
| 13ec16c606 | |||
| 7d150e7e89 | |||
| 7d61948d91 | |||
| 6371d364e1 | |||
| ded9c9140d | |||
| 7c8a108e28 | |||
| 2a18ffcdba | |||
| 381d0d5e81 | |||
| be126acfd1 | |||
| fc2f5cfe4d | |||
| 9878902a89 | |||
| f1230d46f3 | |||
| d8822392f1 | |||
| 1d9cf71517 | |||
| 73de3cc91d | |||
| 2160e87fef | |||
| 88249ba8aa | |||
| 2856617fb3 | |||
| d822980d5a | |||
| b5ba59b413 | |||
| d71cbe344a | |||
| 1148e0163c | |||
| cf36c7adb1 | |||
| eac6998e17 | |||
| 17afbffdb5 | |||
| 072a439c2d | |||
| dbe01a17d2 | |||
| b3503cdede | |||
| c533600983 | |||
| 5847fbb6b6 | |||
| 1876b30c1b | |||
| 5c71f55993 | |||
| 9c0960d03d | |||
| 29a395f3f4 | |||
| a676b8d8e6 | |||
| 7ab0be3b62 | |||
| 115853fed2 | |||
| 60beb81ae4 | |||
| 5310375d42 | |||
| 7ce29e3a09 | |||
| 42c65f4f16 | |||
| bf2cc2a4d5 | |||
| 6d6d7121f6 | |||
| 2ab67380d6 | |||
| 1ac6439690 | |||
| c481841ddf | |||
| 678269c561 | |||
| 83e874cdb6 | |||
| 899387caa1 | |||
| 56c8a9e6fe | |||
| 896288a40b | |||
| fc8ce5e4b9 | |||
| 4affc3c4ce | |||
| 067be54715 | |||
| 0dad2dc64b | |||
| 867b703644 | |||
| 3d398ef6dd | |||
| 90fc5797d5 | |||
| fcfcce88dd | |||
| 85d95b2d8e | |||
| d091d9db6b | |||
| fb8fc54bb1 | |||
| e081ab5239 | |||
| 7b12fd1ad2 | |||
| 80d8c5953e | |||
| a4ec619e5a | |||
| 194037ff41 | |||
| 094d642739 | |||
| 010e03252e | |||
| 98638186b5 | |||
| c5293ef21f | |||
| 366a1c91b8 | |||
| 157450e674 | |||
| 1b8d2bc81c | |||
| f1787c43e5 | |||
| 8fb2622b66 | |||
| 95ea88e932 | |||
| f2b8461bb9 | |||
| 7838a787df | |||
| cc3f2ecb07 | |||
| 833b7c3916 | |||
| 90209f2ca2 | |||
| 2681036c32 | |||
| ee58071ff1 | |||
| e6527de786 | |||
| 3f7e4d817f | |||
| 6eaabc84aa | |||
| 814557435d | |||
| e222f2f6c3 | |||
| 0b7becb161 | |||
| bf795ab7a5 | |||
| 59df38ae8a | |||
| 2d5328fc24 | |||
| 95c82c5cc5 | |||
| fe907b0271 | |||
| e04ff048b8 | |||
| 7d25d07c6d | |||
| 7b0ddfae42 | |||
| 43fa1a7245 | |||
| 7ae74a6a18 | |||
| 9772049295 | |||
| 057530eed0 | |||
| aee34f6365 | |||
| 5519d92243 | |||
| 3d95ac1f93 | |||
| 5c938535be | |||
| 2fdecc551a | |||
| 10204afdb4 | |||
| 55c800c2a5 | |||
| 265b3f9963 | |||
| a8bf2cd1cf | |||
| 4fcd8b3dfe | |||
| e1a1a6344d | |||
| a095c10a25 | |||
| b1ea487e22 | |||
| 47265d0d10 | |||
| 6a41b41a38 | |||
| 2247e43a48 | |||
| d3986080a3 | |||
| 07277e1a5b | |||
| 1d6b5a35bd | |||
| 1ff6ee14ac | |||
| 39d7fc748f | |||
| 4d3a69cf6a | |||
| ec39f22ad8 | |||
| b806522751 | |||
| 256fa897a7 | |||
| 5c812eed6c | |||
| f0b22f9119 | |||
| 7e1884acb5 | |||
| 9512521783 | |||
| da7904a767 | |||
| 51343a171d | |||
| 3a0c1db168 | |||
| bd21692323 | |||
| 5ae245bdca | |||
| d3052cd97d | |||
| 336f85a31c | |||
| b01f7c796e | |||
| 56f438fe47 | |||
| baa5199b83 | |||
| 23ca62b304 | |||
| 2c3511195c | |||
| d31ef481f3 | |||
| a490da5e5c | |||
| 72d3576257 | |||
| ebd93a55a0 | |||
| 4d01e1afe6 | |||
| 9988330613 | |||
| d2e51e97c0 | |||
| 9f94465979 | |||
| 9ff1386751 | |||
| 5fca35f0b1 | |||
| d23c763441 | |||
| fa058c4783 | |||
| e0ddd5f045 | |||
| d83526ff5c | |||
| b7443451a4 | |||
| e90e4a22c4 | |||
| 3a53172145 | |||
| 1dfcb7bc29 | |||
| 897bb76858 | |||
| bcc67269ab | |||
| 4d24c654b9 | |||
| cba44b091b | |||
| 8235717502 | |||
| 3d29d76cd4 | |||
| f2d8f8a41b | |||
| 4b1c7e7e3c | |||
| 1cba278876 | |||
| 766898fdf9 | |||
| 13e91d594b | |||
| ca0a0da19f | |||
| cde5367f38 | |||
| 466463ebc3 | |||
| aee255a6ee | |||
| daf7598774 | |||
| 6de5cf8925 | |||
| 82cabce86e | |||
| 1d6b7e1b2e | |||
| 1c9458d056 | |||
| 4e29f2ae8b | |||
| 69321636b5 | |||
| d190b254bd | |||
| 51a630995a | |||
| 3a74f0726c | |||
| efa6c7bba0 | |||
| 52e5817327 | |||
| 79ddfc65d5 | |||
| 6acb1fd92a | |||
| 005a96f3d3 | |||
| e39e0910a1 | |||
| 56a6cee8f2 | |||
| b8141542f8 | |||
| 8fc9a90207 | |||
| 13d707d98d | |||
| aae0ff6e7a | |||
| 69c7b5a0d5 | |||
| d1ad3115fa | |||
| 770af402a4 | |||
| bd2a1b8886 | |||
| 3236c0b93a | |||
| 51aacc3f38 | |||
| 397c6f46f9 | |||
| d00f78f859 | |||
| 29fec2e0de | |||
| 88d28665ef | |||
| de1f4da258 | |||
| 7985be57ab | |||
| a835e7aaa2 | |||
| 22958cfbb1 | |||
| c4dc5eb9e1 | |||
| db758f386e | |||
| 3fb3eefa94 | |||
| 9340dff45d | |||
| 2d6c756e70 | |||
| 021cfd1737 | |||
| 03e965d449 | |||
| 34f72544d8 | |||
| d839ea9781 | |||
| 2b7f13fdbb | |||
| 7557a3a4ae | |||
| fcecba484f | |||
| fa85a0a0bd | |||
| dc64bfeba2 | |||
| 871b73c48d | |||
| 5dcff91d27 | |||
| 0041fc1dab | |||
| 314242ab08 | |||
| 09e8ddfd74 | |||
| 4cea483a87 | |||
| 99aa616188 | |||
| 444c4602c1 | |||
| 5fc438a0be | |||
| 5b6eac7140 | |||
| 7cdd184197 | |||
| be153b84cb | |||
| 06c53e2251 | |||
| 695519bdf5 | |||
| bf7d033ab2 | |||
| df67795c4a | |||
| 72c1696f43 | |||
| 8eca3683c9 | |||
| 80c17b4913 | |||
| e5050f10bb | |||
| 3a3ac83ab5 | |||
| e912e4de57 | |||
| 8dee1f0d80 | |||
| 53594ada66 | |||
| 848ed1ad72 | |||
| 307e807c8f | |||
| 6a27780d56 | |||
| 57f98dbb4a | |||
| 5af7d83ec1 | |||
| 4a6f77f43a | |||
| c96f9fb635 | |||
| e3a477a243 | |||
| 9fcd641143 | |||
| 6d1cbc5a64 | |||
| ec71060d98 | |||
| 03f706fb85 | |||
| 7ad87bd3ee | |||
| ff4570abac | |||
| c819f2f0e3 | |||
| 4e088f6183 | |||
| 1b16ea6f53 | |||
| c2cdb1264d | |||
| f262503bc8 | |||
| b2ba216cd1 | |||
| 94ba7f8e45 | |||
| a267cf59c7 | |||
| 79e8bef289 | |||
| 99e3b5f33b | |||
| afbe64f3ff | |||
| 43b1a73ae0 | |||
| d08eeb8a2d | |||
| 7c39e5c974 | |||
| cd49334199 | |||
| dd59f0bc6d | |||
| cf2d83a1ea | |||
| d5b6130936 | |||
| c0e95ea18b | |||
| 4ae29b0075 | |||
| f21a81d7ac | |||
| 67c726a141 | |||
| 34e35cd493 | |||
| a17af070c5 | |||
| fbe0a26800 | |||
| 25ad99df94 | |||
| 6338e7b8eb | |||
| 1b9846d519 | |||
| a4ece13a1d | |||
| ff5f50e3ec | |||
| 066b8430a0 | |||
| 022a08f5a1 | |||
| 2b54a91f3d | |||
| 2d01633372 | |||
| 5dc01069fc | |||
| d450008833 | |||
| a37fff6eb5 | |||
| 6604675bf9 | |||
| 1965cc2347 | |||
| 312ca27906 | |||
| 0bceadbd9a | |||
| dfc3daabcd | |||
| b9ba9adc1f | |||
| f112d45e1a | |||
| 88f139873c | |||
| 52f81274f0 | |||
| 35a50209be | |||
| 9728c136f5 | |||
| bacd1d81fd | |||
| d317c5bf03 | |||
| 9d72314b9c | |||
| f36227b506 | |||
| debd840db4 | |||
| 1531629fcd | |||
| 2cc7243573 | |||
| 269d9a6bc6 | |||
| 7bc325fa08 | |||
| 244130fc1b | |||
| a67791b8aa | |||
| 21e46a5c3b | |||
| 2df2f850d5 | |||
| 406d26ec1c | |||
| 68c1aaf433 | |||
| 9ef577dbdd | |||
| 982ecbc015 | |||
| 7e44b5abd5 | |||
| 6dbb1a0c1f | |||
| 94b1c04fa6 | |||
| 9758276f1c | |||
| 971263c52d | |||
| 2cc2d05c2f | |||
| 1df03e137b | |||
| 9b58e7bb4d | |||
| 69ecf0251d | |||
| 350a4d8825 | |||
| 44f447df7b | |||
| e8c4df4897 | |||
| 5ee16124bc | |||
| 8702ac72f0 | |||
| 004552ec30 | |||
| 2f28400234 | |||
| 78d213d686 | |||
| 1a71dda243 | |||
| b0169b0edf | |||
| 21822f259c | |||
| ae79f03e61 | |||
| 4f79607015 | |||
| d8576d4c50 | |||
| 2f4042da8f | |||
| c1397e331b | |||
| cd182b3265 | |||
| 28f2d7b84a | |||
| e8de7908fd | |||
| 419c4c5592 | |||
| a9a8346f58 | |||
| 7e1b3f861f | |||
| f4204e1268 | |||
| 60f35cd0aa | |||
| bbcdaa94a1 | |||
| 8f6e8c00ef | |||
| 24cb347b10 | |||
| d1b4e9464f | |||
| 18f7bfab7f | |||
| d84b4bcf9a | |||
| 57d919798a | |||
| efeeebb0a2 | |||
| 77a587abe8 | |||
| bc1409ba6c | |||
| 6f05b2af8a | |||
| d441bef33e | |||
| 7b1f6ee483 | |||
| 7f587dc389 | |||
| 332960922a | |||
| ac03f73cf9 | |||
| fa847ef2fc | |||
| b19360ac75 | |||
| 562569dc6b | |||
| a18aa51f5a | |||
| b492ac43f8 | |||
| 5bf3efb247 | |||
| 2d833c32b0 | |||
| 19bade01b8 | |||
| 8dbb03114d | |||
| 1b991ba432 | |||
| 9c48850bb7 | |||
| 3e6f5ff11c | |||
| 57c3d8af8b | |||
| 8530451c8b | |||
| 69a5c0b5b3 | |||
| a7c36dcde6 | |||
| dff6fc21f4 | |||
| 895e0250d9 | |||
| fd538cbaff | |||
| 27ce6fa203 | |||
| 8efe194682 | |||
| 947882d16f | |||
| f07e927103 | |||
| a61b85b494 | |||
| 415e96dec6 | |||
| abc7f135f3 | |||
| 4b93207def | |||
| f004ae6a41 | |||
| ac6120adc4 | |||
| 0c4a049a80 | |||
| 8403101d0f | |||
| 47d47afd73 | |||
| 1430801888 | |||
| 6efc7e254b | |||
| d9c2d58519 | |||
| ace1339811 | |||
| a5171a1701 | |||
| 22865b8af4 | |||
| a50211ab95 | |||
| 31677adaa0 | |||
| 247a19766c | |||
| 1f09cbd609 | |||
| 731e4dc31e | |||
| b6ee0764ff | |||
| cd4bf9a28b | |||
| f56b7ff79e | |||
| 645ea683ee | |||
| 67e22fca6b | |||
| a726ba8ea7 | |||
| 23fcaa1bab | |||
| 431ba06742 | |||
| ad684a6f6b | |||
| d3438f4938 | |||
| cafdc21bf2 | |||
| ceb9bd3342 | |||
| 196c0e6cbc | |||
| e2cb7c953c | |||
| 426b3c8522 | |||
| 9648cf257f | |||
| ed8857552b | |||
| 31d8687f67 | |||
| 991407a2cf | |||
| 13b80eda8a | |||
| 6677949614 | |||
| 080dcd92d7 | |||
| 31b0bcf041 | |||
| 5f42ca66fe | |||
| 36664133f8 | |||
| a3106bffca | |||
| c9d003ca6d | |||
| 94a26fb831 | |||
| 1b026f1354 | |||
| 0517e8a530 | |||
| 330f40cc18 | |||
| 44366db4d5 | |||
| a9fea34ac1 | |||
| 201704dc9d | |||
| 93bf28b87d | |||
| bd9c6834b7 | |||
| 96ad2bcdef | |||
| 14c03f0b37 | |||
| 62ecb1af50 | |||
| 9d4ca6293f | |||
| 2296b80edb | |||
| 5bd30fe3dc | |||
| 5be499887d | |||
| 5577d48dcf | |||
| 7124963c56 | |||
| 4377808896 | |||
| e92a281fd8 | |||
| b4c3f9678a | |||
| 82f79c466a | |||
| d428f316b4 | |||
| 828f4877b6 | |||
| 09e8e8e6b3 | |||
| 77c8051ec6 | |||
| 819c5e823c | |||
| 6bae056e3a | |||
| bb869a51fd | |||
| 25b3d08bb9 | |||
| 66eb890462 | |||
| cde8ec8262 | |||
| 50f8f78b8d | |||
| ef1429a639 | |||
| 85a011eb84 | |||
| b5e585834d | |||
| ae298fbc51 | |||
| 1bb9a13c17 | |||
| c02e1ed006 | |||
| 178c53ee84 | |||
| 2c23dbd2be | |||
| 3e017625a9 | |||
| 124037ce27 | |||
| bc166a713d | |||
| 364a9fa7d7 | |||
| f4546ba188 | |||
| 5de2a8f6ec | |||
| 2365cd2978 | |||
| e8dd3511db | |||
| e7f1b822f7 | |||
| ae40a9736a | |||
| 08cc93d337 | |||
| 5fdd9b773c | |||
| c9eee2e075 | |||
| e3e7ac1e68 | |||
| 10212bd38b | |||
| 4bec02f47f | |||
| 6556b3eb9b | |||
| 79f888fe47 | |||
| cc92f3829e | |||
| 9393bf7f79 | |||
| 1b1d4399a9 | |||
| b2ad259a7c | |||
| 33ffbe151f | |||
| 60db0ff775 | |||
| 7ecac185ac | |||
| ac5c264090 | |||
| fcfdd4e323 | |||
| ea65bb2c0a | |||
| b340055588 | |||
| 2918039b6f | |||
| e8fd1f262a | |||
| 8887401644 | |||
| f892db6ee8 | |||
| b76495fa8f | |||
| 829532c572 | |||
| 463d4ad3fd | |||
| 0f0e41d5a4 | |||
| 875eb1e773 | |||
| 2d55562dd3 | |||
| cbe40fde92 | |||
| 97e62fdd34 | |||
| f295289774 | |||
| 1f69b4f271 | |||
| 20a5a844db | |||
| aa799342e5 | |||
| ae8cb18f63 | |||
| 8f53b6f233 | |||
| 1a7a2f848e | |||
| 887dd9aa21 | |||
| e1e4ccf318 | |||
| 9e385215ce | |||
| 378fbedfa4 | |||
| ee6edd2404 | |||
| f0f801ba3f | |||
| 6a7e06b3d5 | |||
| 48ab233d0f | |||
| c84e063114 | |||
| b9f01b63cc | |||
| 8d316ce9f0 | |||
| 0019b93e8d | |||
| 2d66c6fb53 | |||
| b629f674ca | |||
| 670622dfd7 | |||
| 428d37afd4 | |||
| f5ec665115 | |||
| 92143df65c | |||
| d428f342f7 | |||
| e2663f06ba | |||
| 9cf592ee26 | |||
| 4258ac752e | |||
| 8ec8fff57c | |||
| f35c2ead0f | |||
| cf15b79e35 | |||
| 007d488201 | |||
| 6b7b797089 | |||
| 5beffed9b9 | |||
| 532ec6f8f3 | |||
| a46fb7ba36 | |||
| 15d4f6354d | |||
| bc2f2de6c2 | |||
| 77cea99b35 | |||
| efec35afa6 | |||
| 993711ccfe | |||
| d1ef912984 | |||
| 70b50bd096 | |||
| c2401e7a75 | |||
| b63c6223b0 | |||
| d2f8ee8b81 | |||
| 91e68c0580 | |||
| a591dcec48 | |||
| 1898fcd741 | |||
| 8bc8f14c64 | |||
| 09cf5d23ea | |||
| bec5cfe4a6 | |||
| 59f7595050 | |||
| 9e20d25093 | |||
| 155432b4b8 | |||
| 0c614516fb | |||
| 1734d21b8e | |||
| 825233309d | |||
| bcf10cc0b2 | |||
| 0f4e5a4585 | |||
| aaa73eb196 | |||
| 9be149942a | |||
| 707c7a1a53 | |||
| 2ab14cdbf5 | |||
| b79fd8860c | |||
| 6f6eb1fb95 | |||
| dcb3e1c0e4 | |||
| c50ec21f39 | |||
| 3176ee72fe | |||
| 894110ba08 | |||
| cc37da9a56 | |||
| 87572bb083 | |||
| a74de39879 | |||
| 1d27309e53 | |||
| 3f00885d2c | |||
| ea3b1a7438 | |||
| 3dd57e9f38 | |||
| a639143e94 | |||
| a4dceb0b74 | |||
| b3c78ce1b1 | |||
| 1c82fdf048 | |||
| ddf9284ece | |||
| 438be4b8e5 | |||
| 8f43b95129 | |||
| faa2be431f | |||
| ec303e485f | |||
| 1cdcc6d190 | |||
| 16de523e78 | |||
| b24abc6ddd | |||
| a450cd5b01 | |||
| 2e3b7a2c09 | |||
| ef1f44f873 | |||
| 22bba877d7 | |||
| 7142db3fac | |||
| 00e81e87de | |||
| 2f082b9f85 | |||
| 37cf572c0c | |||
| 33c5254d6f | |||
| faa82ca9d6 | |||
| 0d35ea5207 | |||
| c9e5230e37 | |||
| 86e2657613 | |||
| 579645c07c | |||
| c23470af40 | |||
| 869cbe2e81 | |||
| 6c5ec3d2e9 | |||
| 38423a9f37 | |||
| 329a938bf8 | |||
| 411931cc27 | |||
| 7b10e52808 | |||
| 890ee846f7 | |||
| 6a92ea74fc | |||
| 07cf96c5ce | |||
| 636c5f17f5 | |||
| 4a2ee91700 | |||
| 8e9d605248 | |||
| a4e6738353 | |||
| 0f815a0085 | |||
| 0c8c108bd1 | |||
| 04941212cd | |||
| 9ad45a3ca3 | |||
| 176c8e9b93 | |||
| f537588228 | |||
| e593c04001 | |||
| 1095d7808c | |||
| d29dccba69 | |||
| a4098919b9 | |||
| 09e7ff0582 | |||
| 09177be8f7 | |||
| eaa08bada4 | |||
| 5f93d55dab | |||
| dfc0d518f8 | |||
| eace3a0bf0 | |||
| 92adbe0983 | |||
| aadbc3dd01 | |||
| d01a28c57f | |||
| 4ee99a78b2 | |||
| bd9b37a5a0 | |||
| 7947d8b75d | |||
| 3408e467d5 | |||
| a0237a19d9 | |||
| eb15599c01 | |||
| 8c9d0d171c | |||
| a39f4c5eab | |||
| 239dffcbdf | |||
| 3af3df0544 | |||
| 7e30d043eb | |||
| 4cbaee6806 | |||
| 8297ca7e85 | |||
| da1c350067 | |||
| c8c7512600 | |||
| 9b6e12497e | |||
| 50e7deeb32 | |||
| 7e3acd0213 | |||
| e60420cb2c | |||
| c733be5611 | |||
| 4fbe93e62d | |||
| bc2ca0b386 | |||
| ccc3eeebe8 | |||
| 63ee6ef79a | |||
| cf3ac50d22 | |||
| cdf634dc41 | |||
| 21116f90a7 | |||
| 29dd0e172c | |||
| 2d5083179c | |||
| a96de39b28 | |||
| c93812179f | |||
| 26809c4b6b | |||
| 0cce5b021e | |||
| d54c1b07ce | |||
| 1a38cc2c0c | |||
| e188e1dd04 | |||
| ae18e00b13 | |||
| c18b6ec00b | |||
| 53c7bb0338 | |||
| dfa3be78e4 | |||
| 932bb1145b | |||
| 46f4f5ccbe | |||
| 7f851c46f4 | |||
| 82ae042f1c | |||
| 8ca3aab363 | |||
| f95cd60cfd | |||
| ab1e47edb4 | |||
| 6a695d2c72 | |||
| 421d73b28a | |||
| 042f67506c | |||
| 96e5513cdb | |||
| cc30752eb7 | |||
| ade061bf3c | |||
| 616945a963 | |||
| 3201fac36c | |||
| 84551df36a | |||
| b32899f101 | |||
| b4a0f81eda | |||
| 95d035f00b | |||
| 666fbecc01 | |||
| 6f8306cc18 | |||
| b0d5b9c767 | |||
| 3648c0f26a | |||
| f1b4fdd8b0 | |||
| 12658f4fb0 | |||
| ed92f9d28e | |||
| a1440621f9 | |||
| 48f8a05bae | |||
| 4701c22b67 | |||
| 7f841c1fca | |||
| 4f56dce9f7 | |||
| 4e9fb1bbce | |||
| da47b43ad3 | |||
| 87152e6403 | |||
| 895c123b13 | |||
| 35476e2c28 | |||
| 29909e07e8 | |||
| 9ac103187f | |||
| ce1494895e | |||
| 41d2f6b0e2 |
-10
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="src" path="src"/>
|
||||
<classpathentry kind="src" path="gen"/>
|
||||
<classpathentry kind="con" path="com.android.ide.eclipse.adt.ANDROID_FRAMEWORK"/>
|
||||
<classpathentry exported="true" kind="con" path="com.android.ide.eclipse.adt.LIBRARIES"/>
|
||||
<classpathentry exported="true" kind="con" path="com.android.ide.eclipse.adt.DEPENDENCIES"/>
|
||||
<classpathentry kind="lib" path="libs/limelight-common.jar"/>
|
||||
<classpathentry kind="output" path="bin/classes"/>
|
||||
</classpath>
|
||||
@@ -0,0 +1,4 @@
|
||||
issuesOpened: >
|
||||
If this is a question about Moonlight or you need help troubleshooting a streaming problem, please use the help channels on our [Discord server](https://moonlight-stream.org/discord) instead of GitHub issues. There are many more people available on Discord to help you and answer your questions.<br /><br />
|
||||
This issue tracker should only be used for specific bugs or feature requests.<br /><br />
|
||||
Thank you, and happy streaming!
|
||||
@@ -0,0 +1,8 @@
|
||||
# ProBot No Response (https://probot.github.io/apps/no-response/)
|
||||
|
||||
daysUntilClose: 7
|
||||
responseRequiredLabel: 'need more info'
|
||||
closeComment: >
|
||||
This issue has been automatically closed because there was no response to a
|
||||
request for more information from the issue opener. Please leave a comment or
|
||||
open a new issue if you have additional information related to this issue.
|
||||
@@ -0,0 +1,14 @@
|
||||
# ProBot Stale (https://probot.github.io/apps/stale/)
|
||||
|
||||
daysUntilStale: 90
|
||||
daysUntilClose: 7
|
||||
exemptLabels:
|
||||
- accepted
|
||||
- bug
|
||||
- enhancement
|
||||
- meta
|
||||
staleLabel: stale
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs.
|
||||
closeComment: false
|
||||
+44
-1
@@ -1 +1,44 @@
|
||||
/bin/*
|
||||
# built application files
|
||||
*.apk
|
||||
*.ap_
|
||||
*.aab
|
||||
output.json
|
||||
output-metadata.json
|
||||
out/
|
||||
|
||||
# files for the dex VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# generated files
|
||||
bin/
|
||||
gen/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Windows thumbnail db
|
||||
Thumbs.db
|
||||
|
||||
# OSX files
|
||||
.DS_Store
|
||||
|
||||
# Eclipse project files
|
||||
.classpath
|
||||
.project
|
||||
|
||||
# Android Studio
|
||||
.idea
|
||||
#.idea/workspace.xml - remove # and delete .idea if it better suit your needs.
|
||||
.gradle
|
||||
build/
|
||||
*.iml
|
||||
|
||||
# Compiled JNI libraries folder
|
||||
**/jniLibs
|
||||
app/.externalNativeBuild/
|
||||
|
||||
# NDK stuff
|
||||
.cxx/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "app/src/main/jni/moonlight-core/moonlight-common-c"]
|
||||
path = app/src/main/jni/moonlight-core/moonlight-common-c
|
||||
url = https://github.com/moonlight-stream/moonlight-common-c.git
|
||||
@@ -1,2 +0,0 @@
|
||||
*** SESSION Sep 21, 2013 18:55:11.17 -------------------------------------------
|
||||
*** SESSION Sep 21, 2013 18:55:55.08 -------------------------------------------
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
-3
@@ -1,3 +0,0 @@
|
||||
com.android.ide.eclipse.adt.fixLegacyEditors=1
|
||||
com.android.ide.eclipse.adt.sdk=C\:\\Users\\Andrew\\Desktop\\ADT\\adt-bundle-windows-x86_64-20130917\\sdk
|
||||
eclipse.preferences.version=1
|
||||
@@ -1,4 +0,0 @@
|
||||
eclipse.preferences.version=1
|
||||
spelling_locale_initialized=true
|
||||
useAnnotationsPrefPage=true
|
||||
useQuickDiffPrefPage=true
|
||||
@@ -1,2 +0,0 @@
|
||||
eclipse.preferences.version=1
|
||||
version=1
|
||||
@@ -1,13 +0,0 @@
|
||||
content_assist_proposals_background=255,255,255
|
||||
content_assist_proposals_foreground=0,0,0
|
||||
eclipse.preferences.version=1
|
||||
fontPropagated=true
|
||||
org.eclipse.jdt.ui.editor.tab.width=
|
||||
org.eclipse.jdt.ui.formatterprofiles.version=12
|
||||
org.eclipse.jdt.ui.javadoclocations.migrated=true
|
||||
org.eclipse.jface.textfont=1|Courier New|10.0|0|WINDOWS|1|0|0|0|0|0|0|0|0|1|0|0|0|0|Courier New;
|
||||
proposalOrderMigrated=true
|
||||
spelling_locale_initialized=true
|
||||
tabWidthPropagated=true
|
||||
useAnnotationsPrefPage=true
|
||||
useQuickDiffPrefPage=true
|
||||
@@ -1,5 +0,0 @@
|
||||
PROBLEMS_FILTERS_MIGRATE=true
|
||||
eclipse.preferences.version=1
|
||||
platformState=1379804095671
|
||||
quickStart=false
|
||||
tipsAndTricks=true
|
||||
@@ -1,2 +0,0 @@
|
||||
eclipse.preferences.version=1
|
||||
showIntro=false
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<typeInfoHistroy/>
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<qualifiedTypeNameHistroy/>
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<section name="Workbench">
|
||||
<section name="org.eclipse.jdt.internal.ui.packageview.PackageExplorerPart">
|
||||
<item value="true" key="group_libraries"/>
|
||||
<item value="false" key="linkWithEditor"/>
|
||||
<item value="2" key="layout"/>
|
||||
<item value="1" key="rootMode"/>
|
||||
<item value="<?xml version="1.0" encoding="UTF-8"?>
<packageExplorer group_libraries="1" layout="2" linkWithEditor="0" rootMode="1" workingSetName="">
<customFilters userDefinedPatternsEnabled="false">
<xmlDefinedFilters>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.LibraryFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.LocalTypesFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.StaticsFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.ClosedProjectsFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.NonSharedProjectsFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.NonJavaElementFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.ContainedLibraryFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.CuAndClassFileFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.NonJavaProjectsFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.internal.ui.PackageExplorer.EmptyInnerPackageFilter" isEnabled="true"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.PackageDeclarationFilter" isEnabled="true"/>
<child filterId="org.eclipse.jdt.internal.ui.PackageExplorer.EmptyPackageFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.ImportDeclarationFilter" isEnabled="true"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.FieldsFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.internal.ui.PackageExplorer.HideInnerClassFilesFilter" isEnabled="true"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.NonPublicFilter" isEnabled="false"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer_patternFilterId_.*" isEnabled="true"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.EmptyLibraryContainerFilter" isEnabled="true"/>
<child filterId="org.eclipse.jdt.ui.PackageExplorer.SyntheticMembersFilter" isEnabled="true"/>
</xmlDefinedFilters>
</customFilters>
</packageExplorer>" key="memento"/>
|
||||
</section>
|
||||
<section name="JavaElementSearchActions">
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<section name="Workbench">
|
||||
<section name="ChooseWorkspaceDialogSettings">
|
||||
<item value="185" key="DIALOG_Y_ORIGIN"/>
|
||||
<item value="381" key="DIALOG_X_ORIGIN"/>
|
||||
</section>
|
||||
<section name="WORKBENCH_SETTINGS">
|
||||
<list key="ENABLED_TRANSFERS">
|
||||
</list>
|
||||
</section>
|
||||
<section name="ExternalProjectImportWizard">
|
||||
<item value="false" key="WizardProjectsImportPage.STORE_ARCHIVE_SELECTED"/>
|
||||
<item value="false" key="WizardProjectsImportPage.STORE_COPY_PROJECT_ID"/>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<section name="Workbench">
|
||||
<section name="org.eclipse.ui.internal.QuickAccess">
|
||||
<item value="1025" key="dialogWidth"/>
|
||||
<item value="525" key="dialogHeight"/>
|
||||
<list key="orderedProviders">
|
||||
</list>
|
||||
<list key="textArray">
|
||||
</list>
|
||||
<list key="orderedElements">
|
||||
</list>
|
||||
<list key="textEntries">
|
||||
</list>
|
||||
</section>
|
||||
<section name="ImportExportAction">
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<workingSetManager>
|
||||
<workingSet aggregate="true" factoryID="org.eclipse.ui.internal.WorkingSetFactory" id="1379804109849_0" label="Window Working Set" name="Aggregate for window 1379804109848"/>
|
||||
<workingSet aggregate="true" factoryID="org.eclipse.ui.internal.WorkingSetFactory" id="1379804153983_1" label="Window Working Set" name="Aggregate for window 1379804153983"/>
|
||||
</workingSetManager>
|
||||
@@ -1 +0,0 @@
|
||||
org.eclipse.core.runtime=1
|
||||
@@ -1,33 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>Limelight</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>com.android.ide.eclipse.adt.ResourceManagerBuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>com.android.ide.eclipse.adt.PreCompilerBuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>com.android.ide.eclipse.adt.ApkBuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>com.android.ide.eclipse.adt.AndroidNature</nature>
|
||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
||||
@@ -1,11 +0,0 @@
|
||||
eclipse.preferences.version=1
|
||||
org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
|
||||
org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6
|
||||
org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
|
||||
org.eclipse.jdt.core.compiler.compliance=1.6
|
||||
org.eclipse.jdt.core.compiler.debug.lineNumber=generate
|
||||
org.eclipse.jdt.core.compiler.debug.localVariable=generate
|
||||
org.eclipse.jdt.core.compiler.debug.sourceFile=generate
|
||||
org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
|
||||
org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
|
||||
org.eclipse.jdt.core.compiler.source=1.6
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
language: android
|
||||
dist: trusty
|
||||
|
||||
git:
|
||||
depth: 1
|
||||
|
||||
android:
|
||||
components:
|
||||
- tools
|
||||
- platform-tools
|
||||
- build-tools-30.0.0
|
||||
- android-30
|
||||
|
||||
before_install:
|
||||
- sdkmanager --list
|
||||
|
||||
install:
|
||||
- yes | sdkmanager "ndk;21.0.6113669"
|
||||
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.limelight"
|
||||
android:versionCode="16"
|
||||
android:versionName="2.2.1.2" >
|
||||
|
||||
<uses-sdk
|
||||
android:minSdkVersion="16"
|
||||
android:targetSdkVersion="19" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme" >
|
||||
<activity
|
||||
android:name="com.limelight.Connection"
|
||||
android:label="@string/app_name" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="tv.ouya.intent.category.APP" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="com.limelight.Game"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection"
|
||||
android:label="@string/title_activity_game"
|
||||
android:parentActivityName="com.limelight.Connection"
|
||||
android:theme="@style/FullscreenTheme" >
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="com.limelight.Connection" />
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -20,7 +20,7 @@ function p_h264raw.dissector(buf, pkt, root)
|
||||
|
||||
local i = 0
|
||||
local data_start = -1
|
||||
while i < buf:len do
|
||||
while i < buf:len() do
|
||||
-- Make sure we have a potential start sequence and type
|
||||
if buf:len() - i < 5 then
|
||||
-- We need more data
|
||||
|
||||
@@ -1,63 +1,34 @@
|
||||
#Limelight
|
||||
# Moonlight Android
|
||||
|
||||
Limelight is an open source implementation of NVIDIA's GameStream, as used by the NVIDIA Shield.
|
||||
We reverse engineered the Shield streaming software, and created a version that can be run on any Android device.
|
||||
[](https://travis-ci.org/moonlight-stream/moonlight-android)
|
||||
|
||||
Limelight will allow you to stream your full collection of Steam games from your Windows PC to your Android device,
|
||||
in your own home, or over the internet.
|
||||
[Moonlight for Android](https://moonlight-stream.org) is an open source implementation of NVIDIA's GameStream, as used by the NVIDIA Shield.
|
||||
|
||||
[Limelight-pc](https://github.com/limelight-stream/limelight-pc) is also currently in development for Windows, OS X and Linux. Versions for [iOS](https://github.com/limelight-stream/limelight-ios) and [Windows Phone](https://github.com/limelight-stream/limelight-wp) are also in development.
|
||||
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.
|
||||
|
||||
##Features
|
||||
Moonlight also has a [PC client](https://github.com/moonlight-stream/moonlight-qt) and [iOS/tvOS client](https://github.com/moonlight-stream/moonlight-ios).
|
||||
|
||||
* Streams Steam and all of your games from your PC to your Android device
|
||||
* Full gamepad support for MOGA, Xbox 360, PS3, OUYA, and Shield
|
||||
Check out [the Moonlight wiki](https://github.com/moonlight-stream/moonlight-docs/wiki) for more detailed project information, setup guide, or troubleshooting steps.
|
||||
|
||||
##Features in development
|
||||
## Downloads
|
||||
* [Google Play Store](https://play.google.com/store/apps/details?id=com.limelight)
|
||||
* [Amazon App Store](https://www.amazon.com/gp/product/B00JK4MFN2)
|
||||
* [F-Droid](https://f-droid.org/packages/com.limelight)
|
||||
* [APK](https://github.com/moonlight-stream/moonlight-android/releases)
|
||||
|
||||
* Use mDNS to scan for compatible GeForce Experience (GFE) machines on the network
|
||||
* Choose from the list of available games instead of just launching Steam
|
||||
* Keyboard input
|
||||
## Building
|
||||
* Install Android Studio and the Android NDK
|
||||
* 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.
|
||||
* Build the APK using Android Studio or gradle
|
||||
|
||||
##Installation
|
||||
|
||||
* Download and install Limelight for Android from
|
||||
[XDA](http://forum.xda-developers.com/showthread.php?t=2505510)
|
||||
* Download [GeForce Experience](http://www.geforce.com/geforce-experience) and install on your Windows PC
|
||||
|
||||
##Requirements
|
||||
|
||||
* [GFE compatible](http://shield.nvidia.com/play-pc-games/) computer with GTX 600/700 series GPU
|
||||
* Android device running 4.1 (Jelly Bean) or higher
|
||||
* High-end wireless router (802.11n dual-band recommended)
|
||||
* Exynos/Snapdragon SoC __OR__ Quad-Core 1.4 GHz Cortex-A9 or higher (Tegra 3)
|
||||
|
||||
##Usage
|
||||
|
||||
* Turn on Shield Streaming in the GFE settings
|
||||
* If you are connecting from outside the same network, turn on internet
|
||||
streaming
|
||||
* In Limelight, enter your PC's IP or Hostname and click "Pair"
|
||||
* Accept the pairing confirmation on your PC
|
||||
* In Limelight, click "Start Streaming"
|
||||
* 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
|
||||
|
||||
Check out our [website](http://limelight-stream.com) for project links and information.
|
||||
|
||||
##Authors
|
||||
## Authors
|
||||
|
||||
* [Cameron Gutman](https://github.com/cgutman)
|
||||
* [Diego Waxemberg](https://github.com/dwaxemberg)
|
||||
* [Aaron Neyer](https://github.com/Aaronneyer)
|
||||
* [Andrew Hennessy](https://github.com/yetanothername)
|
||||
|
||||
Limelight is the work of students at [Case Western](http://case.edu) and was
|
||||
Moonlight is the work of students at [Case Western](http://case.edu) and was
|
||||
started as a project at [MHacks](http://mhacks.org).
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 30
|
||||
|
||||
versionName "9.8.2"
|
||||
versionCode = 253
|
||||
}
|
||||
|
||||
flavorDimensions "root"
|
||||
|
||||
productFlavors {
|
||||
root {
|
||||
// Android O has native mouse capture, so don't show the rooted
|
||||
// version to devices running O on the Play Store.
|
||||
maxSdkVersion 25
|
||||
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
arguments "PRODUCT_FLAVOR=root"
|
||||
}
|
||||
}
|
||||
|
||||
applicationId "com.limelight.root"
|
||||
dimension "root"
|
||||
}
|
||||
|
||||
nonRoot {
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
arguments "PRODUCT_FLAVOR=nonRoot"
|
||||
}
|
||||
}
|
||||
|
||||
applicationId "com.limelight"
|
||||
dimension "root"
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
disable 'MissingTranslation'
|
||||
lintConfig file("lint.xml")
|
||||
}
|
||||
|
||||
bundle {
|
||||
language {
|
||||
// Avoid splitting by language, since we allow users
|
||||
// to manually switch language in settings.
|
||||
enableSplit = false
|
||||
}
|
||||
density {
|
||||
// FIXME: This should not be neccessary but we get
|
||||
// weird crashes due to missing drawable resources
|
||||
// when this split is enabled.
|
||||
enableSplit = false
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
|
||||
minifyEnabled true
|
||||
useProguard false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
release {
|
||||
// To whomever is releasing/using an APK in release mode with
|
||||
// Moonlight's official application ID, please stop. I see every
|
||||
// single one of your crashes in my Play Console and it makes
|
||||
// Moonlight's reliability look worse and makes it more difficult
|
||||
// to distinguish real crashes from your crashy VR app. Seriously,
|
||||
// 44 of the *same* native crash in 72 hours and a few each of
|
||||
// several other crashes.
|
||||
//
|
||||
// This is technically not your fault. I would have hoped Google
|
||||
// would validate the signature of the APK before attributing
|
||||
// the crash to it. I asked their Play Store support about this
|
||||
// and they said they don't and don't have plans to, so that sucks.
|
||||
//
|
||||
// In any case, it's bad form to release an APK using someone
|
||||
// else's application ID. There is no legitimate reason, that
|
||||
// anyone would need to comment out the following line, except me
|
||||
// when I release an official signed Moonlight build. If you feel
|
||||
// like doing so would solve something, I can tell you it will not.
|
||||
// You can't upgrade an app while retaining data without having the
|
||||
// same signature as the official version. Nor can you post it on
|
||||
// the Play Store, since that application ID is already taken.
|
||||
// Reputable APK hosting websites similarly validate the signature
|
||||
// is consistent with the Play Store and won't allow an APK that
|
||||
// isn't signed the same as the original.
|
||||
//
|
||||
// I wish any and all people using Moonlight as the basis of other
|
||||
// cool projects the best of luck with their efforts. All I ask
|
||||
// is to please change the applicationId before you publish.
|
||||
//
|
||||
// TL;DR: Leave the following line alone!
|
||||
applicationIdSuffix ".unofficial"
|
||||
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
path "src/main/jni/Android.mk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.bouncycastle:bcprov-jdk15on:1.66'
|
||||
implementation 'org.bouncycastle:bcpkix-jdk15on:1.66'
|
||||
implementation 'org.jcodec:jcodec:0.2.3'
|
||||
implementation 'com.squareup.okhttp3:okhttp:3.12.12'
|
||||
implementation 'com.squareup.okio:okio:1.17.5'
|
||||
implementation 'org.jmdns:jmdns:3.5.5'
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lint>
|
||||
<issue id="InvalidPackage">
|
||||
<ignore path="**/bcpkix-jdk15on-*.jar"/>
|
||||
</issue>
|
||||
</lint>
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
# Don't obfuscate code
|
||||
-dontobfuscate
|
||||
|
||||
# Our code
|
||||
-keep class com.limelight.binding.input.evdev.* {*;}
|
||||
|
||||
# Moonlight common
|
||||
-keep class com.limelight.nvstream.jni.* {*;}
|
||||
|
||||
# Okio
|
||||
-keep class sun.misc.Unsafe {*;}
|
||||
-dontwarn java.nio.file.*
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||
-dontwarn okio.**
|
||||
|
||||
# BouncyCastle
|
||||
-keep class org.bouncycastle.jcajce.provider.asymmetric.* {*;}
|
||||
-keep class org.bouncycastle.jcajce.provider.asymmetric.util.* {*;}
|
||||
-keep class org.bouncycastle.jcajce.provider.asymmetric.rsa.* {*;}
|
||||
-keep class org.bouncycastle.jcajce.provider.digest.** {*;}
|
||||
-keep class org.bouncycastle.jcajce.provider.symmetric.** {*;}
|
||||
-keep class org.bouncycastle.jcajce.spec.* {*;}
|
||||
-keep class org.bouncycastle.jce.** {*;}
|
||||
-dontwarn javax.naming.**
|
||||
|
||||
# jMDNS
|
||||
-dontwarn javax.jmdns.impl.DNSCache
|
||||
-dontwarn org.slf4j.**
|
||||
@@ -0,0 +1,7 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,160 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<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="com.android.providers.tv.permission.READ_EPG_DATA"/>
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.gamepad"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.usb.host"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Disable legacy input emulation on ChromeOS -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.type.pc"
|
||||
android:required="false"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:isGame="true"
|
||||
android:banner="@drawable/atv_banner"
|
||||
android:appCategory="game"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:installLocation="auto"
|
||||
android:gwpAsanMode="always"
|
||||
android:theme="@style/AppTheme">
|
||||
<provider
|
||||
android:name=".PosterContentProvider"
|
||||
android:authorities="poster.${applicationId}"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
</provider>
|
||||
<!-- Samsung multi-window support -->
|
||||
<uses-library
|
||||
android:name="com.sec.android.app.multiwindow"
|
||||
android:required="false" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.android.support.multiwindow"
|
||||
android:value="true" />
|
||||
|
||||
<!-- Samsung DeX support requires explicit placement of android:resizeableActivity="true"
|
||||
in each activity even though it is implied by targeting API 24+ -->
|
||||
|
||||
<activity
|
||||
android:name=".PcView"
|
||||
android:resizeableActivity="true"
|
||||
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="tv.ouya.intent.category.APP" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Small hack to support launcher shortcuts without relaunching over and over again when the back button is pressed -->
|
||||
<activity
|
||||
android:name=".ShortcutTrampoline"
|
||||
android:noHistory="true"
|
||||
android:exported="true"
|
||||
android:resizeableActivity="true"
|
||||
android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="com.limelight.PcView" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".AppView"
|
||||
android:resizeableActivity="true"
|
||||
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="com.limelight.PcView" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".preferences.StreamSettings"
|
||||
android:resizeableActivity="true"
|
||||
android:label="Streaming Settings">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="com.limelight.PcView" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".preferences.AddComputerManually"
|
||||
android:resizeableActivity="true"
|
||||
android:windowSoftInputMode="stateVisible"
|
||||
android:label="Add Computer Manually">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="com.limelight.PcView" />
|
||||
</activity>
|
||||
<!-- This will fall back to sensorLandscape at runtime on Android 4.2 and below -->
|
||||
<activity
|
||||
android:name=".Game"
|
||||
android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection"
|
||||
android:screenOrientation="userLandscape"
|
||||
android:noHistory="true"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@style/StreamTheme"
|
||||
android:preferMinimalPostProcessing="true">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="com.limelight.AppView" />
|
||||
|
||||
<!-- Special metadata for NVIDIA Shield devices to prevent input buffering
|
||||
and most importantly, opt out of mouse acceleration while streaming -->
|
||||
<meta-data
|
||||
android:name="com.nvidia.immediateInput"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="com.nvidia.rawCursorInput"
|
||||
android:value="true" />
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".discovery.DiscoveryService"
|
||||
android:label="mDNS PC Auto-Discovery Service" />
|
||||
<service
|
||||
android:name=".computers.ComputerManagerService"
|
||||
android:label="Computer Management Service" />
|
||||
<service
|
||||
android:name=".binding.input.driver.UsbDriverService"
|
||||
android:label="Usb Driver Service" />
|
||||
|
||||
<activity
|
||||
android:name=".HelpActivity"
|
||||
android:resizeableActivity="true"
|
||||
android:configChanges="mcc|mnc|touchscreen|keyboard|keyboardHidden|navigation|screenLayout|fontScale|uiMode|orientation|screenSize|smallestScreenSize|layoutDirection">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="com.limelight.PcView" />
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,660 @@
|
||||
package com.limelight;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import com.limelight.computers.ComputerManagerListener;
|
||||
import com.limelight.computers.ComputerManagerService;
|
||||
import com.limelight.grid.AppGridAdapter;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
import com.limelight.nvstream.http.NvHTTP;
|
||||
import com.limelight.nvstream.http.PairingManager;
|
||||
import com.limelight.preferences.PreferenceConfiguration;
|
||||
import com.limelight.ui.AdapterFragment;
|
||||
import com.limelight.ui.AdapterFragmentCallbacks;
|
||||
import com.limelight.utils.CacheHelper;
|
||||
import com.limelight.utils.Dialog;
|
||||
import com.limelight.utils.ServerHelper;
|
||||
import com.limelight.utils.ShortcutHelper;
|
||||
import com.limelight.utils.SpinnerDialog;
|
||||
import com.limelight.utils.UiHelper;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Service;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.view.ContextMenu;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ContextMenu.ContextMenuInfo;
|
||||
import android.widget.AbsListView;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.AdapterView.OnItemClickListener;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import android.widget.AdapterView.AdapterContextMenuInfo;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
private AppGridAdapter appGridAdapter;
|
||||
private String uuidString;
|
||||
private ShortcutHelper shortcutHelper;
|
||||
|
||||
private ComputerDetails computer;
|
||||
private ComputerManagerService.ApplistPoller poller;
|
||||
private SpinnerDialog blockingLoadSpinner;
|
||||
private String lastRawApplist;
|
||||
private int lastRunningAppId;
|
||||
private boolean suspendGridUpdates;
|
||||
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 QUIT_ID = 2;
|
||||
private final static int START_WITH_QUIT = 4;
|
||||
private final static int VIEW_DETAILS_ID = 5;
|
||||
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 UUID_EXTRA = "UUID";
|
||||
public final static String NEW_PAIR_EXTRA = "NewPair";
|
||||
public final static String SHOW_HIDDEN_APPS_EXTRA = "ShowHiddenApps";
|
||||
|
||||
private ComputerManagerService.ComputerManagerBinder managerBinder;
|
||||
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
||||
public void onServiceConnected(ComponentName className, IBinder binder) {
|
||||
final ComputerManagerService.ComputerManagerBinder localBinder =
|
||||
((ComputerManagerService.ComputerManagerBinder)binder);
|
||||
|
||||
// Wait in a separate thread to avoid stalling the UI
|
||||
new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Wait for the binder to be ready
|
||||
localBinder.waitForReady();
|
||||
|
||||
// Get the computer object
|
||||
computer = localBinder.getComputer(uuidString);
|
||||
if (computer == null) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a launcher shortcut for this PC (forced, since this is user interaction)
|
||||
shortcutHelper.createAppViewShortcut(computer, true, getIntent().getBooleanExtra(NEW_PAIR_EXTRA, false));
|
||||
shortcutHelper.reportComputerShortcutUsed(computer);
|
||||
|
||||
try {
|
||||
appGridAdapter = new AppGridAdapter(AppView.this,
|
||||
PreferenceConfiguration.readPreferences(AppView.this),
|
||||
computer, localBinder.getUniqueId(),
|
||||
showHiddenApps);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
appGridAdapter.updateHiddenApps(hiddenAppIds, true);
|
||||
|
||||
// Now make the binder visible. We must do this after appGridAdapter
|
||||
// is set to prevent us from reaching updateUiWithServerinfo() and
|
||||
// touching the appGridAdapter prior to initialization.
|
||||
managerBinder = localBinder;
|
||||
|
||||
// Load the app grid with cached data (if possible).
|
||||
// This must be done _before_ startComputerUpdates()
|
||||
// so the initial serverinfo response can update the running
|
||||
// icon.
|
||||
populateAppGridWithCache();
|
||||
|
||||
// Start updates
|
||||
startComputerUpdates();
|
||||
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (isFinishing() || isChangingConfigurations()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Despite my best efforts to catch all conditions that could
|
||||
// cause the activity to be destroyed when we try to commit
|
||||
// I haven't been able to, so we have this try-catch block.
|
||||
try {
|
||||
getFragmentManager().beginTransaction()
|
||||
.replace(R.id.appFragmentContainer, new AdapterFragment())
|
||||
.commitAllowingStateLoss();
|
||||
} catch (IllegalStateException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
public void onServiceDisconnected(ComponentName className) {
|
||||
managerBinder = null;
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
|
||||
// If appGridAdapter is initialized, let it know about the configuration change.
|
||||
// If not, it will pick it up when it initializes.
|
||||
if (appGridAdapter != null) {
|
||||
// Update the app grid adapter to create grid items with the correct layout
|
||||
appGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this));
|
||||
|
||||
try {
|
||||
// Reinflate the app grid itself to pick up the layout change
|
||||
getFragmentManager().beginTransaction()
|
||||
.replace(R.id.appFragmentContainer, new AdapterFragment())
|
||||
.commitAllowingStateLoss();
|
||||
} catch (IllegalStateException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startComputerUpdates() {
|
||||
// Don't start polling if we're not bound or in the foreground
|
||||
if (managerBinder == null || !inForeground) {
|
||||
return;
|
||||
}
|
||||
|
||||
managerBinder.startPolling(new ComputerManagerListener() {
|
||||
@Override
|
||||
public void notifyComputerUpdated(final ComputerDetails details) {
|
||||
// Do nothing if updates are suspended
|
||||
if (suspendGridUpdates) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't care about other computers
|
||||
if (!details.uuid.equalsIgnoreCase(uuidString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.state == ComputerDetails.State.OFFLINE) {
|
||||
// The PC is unreachable now
|
||||
AppView.this.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Display a toast to the user and quit the activity
|
||||
Toast.makeText(AppView.this, getResources().getText(R.string.lost_connection), Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Close immediately if the PC is no longer paired
|
||||
if (details.state == ComputerDetails.State.ONLINE && details.pairState != PairingManager.PairState.PAIRED) {
|
||||
AppView.this.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Disable shortcuts referencing this PC for now
|
||||
shortcutHelper.disableComputerShortcut(details,
|
||||
getResources().getString(R.string.scut_not_paired));
|
||||
|
||||
// Display a toast to the user and quit the activity
|
||||
Toast.makeText(AppView.this, getResources().getText(R.string.scut_not_paired), Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// App list is the same or empty
|
||||
if (details.rawAppList == null || details.rawAppList.equals(lastRawApplist)) {
|
||||
|
||||
// Let's check if the running app ID changed
|
||||
if (details.runningGameId != lastRunningAppId) {
|
||||
// Update the currently running game using the app ID
|
||||
lastRunningAppId = details.runningGameId;
|
||||
updateUiWithServerinfo(details);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
lastRunningAppId = details.runningGameId;
|
||||
lastRawApplist = details.rawAppList;
|
||||
|
||||
try {
|
||||
updateUiWithAppList(NvHTTP.getAppListByReader(new StringReader(details.rawAppList)));
|
||||
updateUiWithServerinfo(details);
|
||||
|
||||
if (blockingLoadSpinner != null) {
|
||||
blockingLoadSpinner.dismiss();
|
||||
blockingLoadSpinner = null;
|
||||
}
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (poller == null) {
|
||||
poller = managerBinder.createAppListPoller(computer);
|
||||
}
|
||||
poller.start();
|
||||
}
|
||||
|
||||
private void stopComputerUpdates() {
|
||||
if (poller != null) {
|
||||
poller.stop();
|
||||
}
|
||||
|
||||
if (managerBinder != null) {
|
||||
managerBinder.stopPolling();
|
||||
}
|
||||
|
||||
if (appGridAdapter != null) {
|
||||
appGridAdapter.cancelQueuedOperations();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Assume we're in the foreground when created to avoid a race
|
||||
// between binding to CMS and onResume()
|
||||
inForeground = true;
|
||||
|
||||
shortcutHelper = new ShortcutHelper(this);
|
||||
|
||||
UiHelper.setLocale(this);
|
||||
|
||||
setContentView(R.layout.activity_app_view);
|
||||
|
||||
UiHelper.notifyNewRootView(this);
|
||||
|
||||
showHiddenApps = getIntent().getBooleanExtra(SHOW_HIDDEN_APPS_EXTRA, false);
|
||||
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);
|
||||
|
||||
TextView label = findViewById(R.id.appListText);
|
||||
setTitle(computerName);
|
||||
label.setText(computerName);
|
||||
|
||||
// Bind to the computer manager service
|
||||
bindService(new Intent(this, ComputerManagerService.class), serviceConnection,
|
||||
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() {
|
||||
try {
|
||||
// Try to load from cache
|
||||
lastRawApplist = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(getCacheDir(), "applist", uuidString));
|
||||
List<NvApp> applist = NvHTTP.getAppListByReader(new StringReader(lastRawApplist));
|
||||
updateUiWithAppList(applist);
|
||||
LimeLog.info("Loaded applist from cache");
|
||||
} catch (IOException | XmlPullParserException e) {
|
||||
if (lastRawApplist != null) {
|
||||
LimeLog.warning("Saved applist corrupted: "+lastRawApplist);
|
||||
e.printStackTrace();
|
||||
}
|
||||
LimeLog.info("Loading applist from the network");
|
||||
// We'll need to load from the network
|
||||
loadAppsBlocking();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadAppsBlocking() {
|
||||
blockingLoadSpinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.applist_refresh_title),
|
||||
getResources().getString(R.string.applist_refresh_msg), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
SpinnerDialog.closeDialogs(this);
|
||||
Dialog.closeDialogs();
|
||||
|
||||
if (managerBinder != null) {
|
||||
unbindService(serviceConnection);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
|
||||
// Display a decoder crash notification if we've returned after a crash
|
||||
UiHelper.showDecoderCrashDialog(this);
|
||||
|
||||
inForeground = true;
|
||||
startComputerUpdates();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
|
||||
inForeground = false;
|
||||
stopComputerUpdates();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
|
||||
super.onCreateContextMenu(menu, v, menuInfo);
|
||||
|
||||
AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo;
|
||||
AppObject selectedApp = (AppObject) appGridAdapter.getItem(info.position);
|
||||
|
||||
menu.setHeaderTitle(selectedApp.app.getAppName());
|
||||
|
||||
if (lastRunningAppId != 0) {
|
||||
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, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit));
|
||||
}
|
||||
else {
|
||||
menu.add(Menu.NONE, START_WITH_QUIT, 1, getResources().getString(R.string.applist_menu_quit_and_start));
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Only add an option to create shortcut if box art is loaded
|
||||
// and when we're in grid-mode (not list-mode).
|
||||
ImageView appImageView = info.targetView.findViewById(R.id.grid_image);
|
||||
if (appImageView != null) {
|
||||
// We have a grid ImageView, so we must be in grid-mode
|
||||
BitmapDrawable drawable = (BitmapDrawable)appImageView.getDrawable();
|
||||
if (drawable != null && drawable.getBitmap() != null) {
|
||||
// We have a bitmap loaded too
|
||||
menu.add(Menu.NONE, CREATE_SHORTCUT_ID, 5, getResources().getString(R.string.applist_menu_scut));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContextMenuClosed(Menu menu) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onContextItemSelected(MenuItem item) {
|
||||
AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo();
|
||||
final AppObject app = (AppObject) appGridAdapter.getItem(info.position);
|
||||
switch (item.getItemId()) {
|
||||
case START_WITH_QUIT:
|
||||
// Display a confirmation dialog first
|
||||
UiHelper.displayQuitConfirmationDialog(this, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
ServerHelper.doStart(AppView.this, app.app, computer, managerBinder);
|
||||
}
|
||||
}, null);
|
||||
return true;
|
||||
|
||||
case START_OR_RESUME_ID:
|
||||
// Resume is the same as start for us
|
||||
ServerHelper.doStart(AppView.this, app.app, computer, managerBinder);
|
||||
return true;
|
||||
|
||||
case QUIT_ID:
|
||||
// Display a confirmation dialog first
|
||||
UiHelper.displayQuitConfirmationDialog(this, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
suspendGridUpdates = true;
|
||||
ServerHelper.doQuit(AppView.this, computer,
|
||||
app.app, managerBinder, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Trigger a poll immediately
|
||||
suspendGridUpdates = false;
|
||||
if (poller != null) {
|
||||
poller.pollNow();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, null);
|
||||
return true;
|
||||
|
||||
case VIEW_DETAILS_ID:
|
||||
Dialog.displayDialog(AppView.this, getResources().getString(R.string.title_details), app.app.toString(), false);
|
||||
return true;
|
||||
|
||||
case HIDE_APP_ID:
|
||||
if (item.isChecked()) {
|
||||
// Transitioning hidden to shown
|
||||
hiddenAppIds.remove(app.app.getAppId());
|
||||
}
|
||||
else {
|
||||
// Transitioning shown to hidden
|
||||
hiddenAppIds.add(app.app.getAppId());
|
||||
}
|
||||
updateHiddenApps(false);
|
||||
return true;
|
||||
|
||||
case CREATE_SHORTCUT_ID:
|
||||
ImageView appImageView = info.targetView.findViewById(R.id.grid_image);
|
||||
Bitmap appBits = ((BitmapDrawable)appImageView.getDrawable()).getBitmap();
|
||||
if (!shortcutHelper.createPinnedGameShortcut(computer, app.app, appBits)) {
|
||||
Toast.makeText(AppView.this, getResources().getString(R.string.unable_to_pin_shortcut), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
return super.onContextItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateUiWithServerinfo(final ComputerDetails details) {
|
||||
AppView.this.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
boolean updated = false;
|
||||
|
||||
// Look through our current app list to tag the running app
|
||||
for (int i = 0; i < appGridAdapter.getCount(); i++) {
|
||||
AppObject existingApp = (AppObject) appGridAdapter.getItem(i);
|
||||
|
||||
// There can only be one or zero apps running.
|
||||
if (existingApp.isRunning &&
|
||||
existingApp.app.getAppId() == details.runningGameId) {
|
||||
// This app was running and still is, so we're done now
|
||||
return;
|
||||
}
|
||||
else if (existingApp.app.getAppId() == details.runningGameId) {
|
||||
// This app wasn't running but now is
|
||||
existingApp.isRunning = true;
|
||||
updated = true;
|
||||
}
|
||||
else if (existingApp.isRunning) {
|
||||
// This app was running but now isn't
|
||||
existingApp.isRunning = false;
|
||||
updated = true;
|
||||
}
|
||||
else {
|
||||
// This app wasn't running and still isn't
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
appGridAdapter.notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateUiWithAppList(final List<NvApp> appList) {
|
||||
AppView.this.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
boolean updated = false;
|
||||
|
||||
// First handle app updates and additions
|
||||
for (NvApp app : appList) {
|
||||
boolean foundExistingApp = false;
|
||||
|
||||
// Try to update an existing app in the list first
|
||||
for (int i = 0; i < appGridAdapter.getCount(); i++) {
|
||||
AppObject existingApp = (AppObject) appGridAdapter.getItem(i);
|
||||
if (existingApp.app.getAppId() == app.getAppId()) {
|
||||
// Found the app; update its properties
|
||||
if (!existingApp.app.getAppName().equals(app.getAppName())) {
|
||||
existingApp.app.setAppName(app.getAppName());
|
||||
updated = true;
|
||||
}
|
||||
|
||||
foundExistingApp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundExistingApp) {
|
||||
// This app must be new
|
||||
appGridAdapter.addApp(new AppObject(app));
|
||||
|
||||
// We could have a leftover shortcut from last time this PC was paired
|
||||
// or if this app was removed then added again. Enable those shortcuts
|
||||
// again if present.
|
||||
shortcutHelper.enableAppShortcut(computer, app);
|
||||
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Next handle app removals
|
||||
int i = 0;
|
||||
while (i < appGridAdapter.getCount()) {
|
||||
boolean foundExistingApp = false;
|
||||
AppObject existingApp = (AppObject) appGridAdapter.getItem(i);
|
||||
|
||||
// Check if this app is in the latest list
|
||||
for (NvApp app : appList) {
|
||||
if (existingApp.app.getAppId() == app.getAppId()) {
|
||||
foundExistingApp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// This app was removed in the latest app list
|
||||
if (!foundExistingApp) {
|
||||
shortcutHelper.disableAppShortcut(computer, existingApp.app, "App removed from PC");
|
||||
appGridAdapter.removeApp(existingApp);
|
||||
updated = true;
|
||||
|
||||
// Check this same index again because the item at i+1 is now at i after
|
||||
// the removal
|
||||
continue;
|
||||
}
|
||||
|
||||
// Move on to the next item
|
||||
i++;
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
appGridAdapter.notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getAdapterFragmentLayoutId() {
|
||||
return PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ?
|
||||
R.layout.app_grid_view_small : R.layout.app_grid_view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveAbsListView(AbsListView listView) {
|
||||
listView.setAdapter(appGridAdapter);
|
||||
listView.setOnItemClickListener(new OnItemClickListener() {
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> arg0, View arg1, int pos,
|
||||
long id) {
|
||||
AppObject app = (AppObject) appGridAdapter.getItem(pos);
|
||||
|
||||
// Only open the context menu if something is running, otherwise start it
|
||||
if (lastRunningAppId != 0) {
|
||||
openContextMenu(arg1);
|
||||
} else {
|
||||
ServerHelper.doStart(AppView.this, app.app, computer, managerBinder);
|
||||
}
|
||||
}
|
||||
});
|
||||
UiHelper.applyStatusBarPadding(listView);
|
||||
registerForContextMenu(listView);
|
||||
listView.requestFocus();
|
||||
}
|
||||
|
||||
public static class AppObject {
|
||||
public final NvApp app;
|
||||
public boolean isRunning;
|
||||
public boolean isHidden;
|
||||
|
||||
public AppObject(NvApp app) {
|
||||
if (app == null) {
|
||||
throw new IllegalArgumentException("app must not be null");
|
||||
}
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return app.getAppName();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
package com.limelight;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
import com.limelight.utils.SpinnerDialog;
|
||||
|
||||
public class HelpActivity extends Activity {
|
||||
|
||||
private SpinnerDialog loadingDialog;
|
||||
private WebView webView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
webView = new WebView(this);
|
||||
setContentView(webView);
|
||||
|
||||
// These allow the user to zoom the page
|
||||
webView.getSettings().setBuiltInZoomControls(true);
|
||||
webView.getSettings().setDisplayZoomControls(false);
|
||||
|
||||
// This sets the view to display the whole page by default
|
||||
webView.getSettings().setUseWideViewPort(true);
|
||||
webView.getSettings().setLoadWithOverviewMode(true);
|
||||
|
||||
// This allows the links to places on the same page to work
|
||||
webView.getSettings().setJavaScriptEnabled(true);
|
||||
|
||||
webView.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public void onPageStarted(WebView view, String url, Bitmap favicon) {
|
||||
if (loadingDialog == null) {
|
||||
loadingDialog = SpinnerDialog.displayDialog(HelpActivity.this,
|
||||
getResources().getString(R.string.help_loading_title),
|
||||
getResources().getString(R.string.help_loading_msg), false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
if (loadingDialog != null) {
|
||||
loadingDialog.dismiss();
|
||||
loadingDialog = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
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());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
// Back goes back through the WebView history
|
||||
// until no more history remains
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack();
|
||||
}
|
||||
else {
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.limelight;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.logging.FileHandler;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class LimeLog {
|
||||
private static final Logger LOGGER = Logger.getLogger(LimeLog.class.getName());
|
||||
|
||||
public static void info(String msg) {
|
||||
LOGGER.info(msg);
|
||||
}
|
||||
|
||||
public static void warning(String msg) {
|
||||
LOGGER.warning(msg);
|
||||
}
|
||||
|
||||
public static void severe(String msg) {
|
||||
LOGGER.severe(msg);
|
||||
}
|
||||
|
||||
public static void setFileHandler(String fileName) throws IOException {
|
||||
LOGGER.addHandler(new FileHandler(fileName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
package com.limelight;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.net.UnknownHostException;
|
||||
|
||||
import com.limelight.binding.PlatformBinding;
|
||||
import com.limelight.binding.crypto.AndroidCryptoProvider;
|
||||
import com.limelight.computers.ComputerManagerListener;
|
||||
import com.limelight.computers.ComputerManagerService;
|
||||
import com.limelight.grid.PcGridAdapter;
|
||||
import com.limelight.grid.assets.DiskAssetLoader;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
import com.limelight.nvstream.http.NvHTTP;
|
||||
import com.limelight.nvstream.http.PairingManager;
|
||||
import com.limelight.nvstream.http.PairingManager.PairState;
|
||||
import com.limelight.nvstream.wol.WakeOnLanSender;
|
||||
import com.limelight.preferences.AddComputerManually;
|
||||
import com.limelight.preferences.GlPreferences;
|
||||
import com.limelight.preferences.PreferenceConfiguration;
|
||||
import com.limelight.preferences.StreamSettings;
|
||||
import com.limelight.ui.AdapterFragment;
|
||||
import com.limelight.ui.AdapterFragmentCallbacks;
|
||||
import com.limelight.utils.Dialog;
|
||||
import com.limelight.utils.HelpLauncher;
|
||||
import com.limelight.utils.ServerHelper;
|
||||
import com.limelight.utils.ShortcutHelper;
|
||||
import com.limelight.utils.UiHelper;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.ActivityManager;
|
||||
import android.app.Service;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.content.res.Configuration;
|
||||
import android.opengl.GLSurfaceView;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.view.ContextMenu;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ContextMenu.ContextMenuInfo;
|
||||
import android.view.View.OnClickListener;
|
||||
import android.widget.AbsListView;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.AdapterView.OnItemClickListener;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.Toast;
|
||||
import android.widget.AdapterView.AdapterContextMenuInfo;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import javax.microedition.khronos.egl.EGLConfig;
|
||||
import javax.microedition.khronos.opengles.GL10;
|
||||
|
||||
public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
private RelativeLayout noPcFoundLayout;
|
||||
private PcGridAdapter pcGridAdapter;
|
||||
private ShortcutHelper shortcutHelper;
|
||||
private ComputerManagerService.ComputerManagerBinder managerBinder;
|
||||
private boolean freezeUpdates, runningPolling, inForeground, completeOnCreateCalled;
|
||||
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
||||
public void onServiceConnected(ComponentName className, IBinder binder) {
|
||||
final ComputerManagerService.ComputerManagerBinder localBinder =
|
||||
((ComputerManagerService.ComputerManagerBinder)binder);
|
||||
|
||||
// Wait in a separate thread to avoid stalling the UI
|
||||
new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Wait for the binder to be ready
|
||||
localBinder.waitForReady();
|
||||
|
||||
// Now make the binder visible
|
||||
managerBinder = localBinder;
|
||||
|
||||
// Start updates
|
||||
startComputerUpdates();
|
||||
|
||||
// Force a keypair to be generated early to avoid discovery delays
|
||||
new AndroidCryptoProvider(PcView.this).getClientCertificate();
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
public void onServiceDisconnected(ComponentName className) {
|
||||
managerBinder = null;
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
|
||||
// Only reinitialize views if completeOnCreate() was called
|
||||
// before this callback. If it was not, completeOnCreate() will
|
||||
// handle initializing views with the config change accounted for.
|
||||
// This is not prone to races because both callbacks are invoked
|
||||
// in the main thread.
|
||||
if (completeOnCreateCalled) {
|
||||
// Reinitialize views just in case orientation changed
|
||||
initializeViews();
|
||||
}
|
||||
}
|
||||
|
||||
private final static int PAIR_ID = 2;
|
||||
private final static int UNPAIR_ID = 3;
|
||||
private final static int WOL_ID = 4;
|
||||
private final static int DELETE_ID = 5;
|
||||
private final static int RESUME_ID = 6;
|
||||
private final static int QUIT_ID = 7;
|
||||
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 void initializeViews() {
|
||||
setContentView(R.layout.activity_pc_view);
|
||||
|
||||
UiHelper.notifyNewRootView(this);
|
||||
|
||||
// Set default preferences if we've never been run
|
||||
PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
|
||||
|
||||
// Set the correct layout for the PC grid
|
||||
pcGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this));
|
||||
|
||||
// Setup the list view
|
||||
ImageButton settingsButton = findViewById(R.id.settingsButton);
|
||||
ImageButton addComputerButton = findViewById(R.id.manuallyAddPc);
|
||||
ImageButton helpButton = findViewById(R.id.helpButton);
|
||||
|
||||
settingsButton.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
startActivity(new Intent(PcView.this, StreamSettings.class));
|
||||
}
|
||||
});
|
||||
addComputerButton.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
Intent i = new Intent(PcView.this, AddComputerManually.class);
|
||||
startActivity(i);
|
||||
}
|
||||
});
|
||||
helpButton.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
HelpLauncher.launchSetupGuide(PcView.this);
|
||||
}
|
||||
});
|
||||
|
||||
getFragmentManager().beginTransaction()
|
||||
.replace(R.id.pcFragmentContainer, new AdapterFragment())
|
||||
.commitAllowingStateLoss();
|
||||
|
||||
noPcFoundLayout = findViewById(R.id.no_pc_found_layout);
|
||||
if (pcGridAdapter.getCount() == 0) {
|
||||
noPcFoundLayout.setVisibility(View.VISIBLE);
|
||||
}
|
||||
else {
|
||||
noPcFoundLayout.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
pcGridAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Assume we're in the foreground when created to avoid a race
|
||||
// between binding to CMS and onResume()
|
||||
inForeground = true;
|
||||
|
||||
// Create a GLSurfaceView to fetch GLRenderer unless we have
|
||||
// a cached result already.
|
||||
final GlPreferences glPrefs = GlPreferences.readPreferences(this);
|
||||
if (!glPrefs.savedFingerprint.equals(Build.FINGERPRINT) || glPrefs.glRenderer.isEmpty()) {
|
||||
GLSurfaceView surfaceView = new GLSurfaceView(this);
|
||||
surfaceView.setRenderer(new GLSurfaceView.Renderer() {
|
||||
@Override
|
||||
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
|
||||
// Save the GLRenderer string so we don't need to do this next time
|
||||
glPrefs.glRenderer = gl10.glGetString(GL10.GL_RENDERER);
|
||||
glPrefs.savedFingerprint = Build.FINGERPRINT;
|
||||
glPrefs.writePreferences();
|
||||
|
||||
LimeLog.info("Fetched GL Renderer: " + glPrefs.glRenderer);
|
||||
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
completeOnCreate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceChanged(GL10 gl10, int i, int i1) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDrawFrame(GL10 gl10) {
|
||||
}
|
||||
});
|
||||
setContentView(surfaceView);
|
||||
}
|
||||
else {
|
||||
LimeLog.info("Cached GL Renderer: " + glPrefs.glRenderer);
|
||||
completeOnCreate();
|
||||
}
|
||||
}
|
||||
|
||||
private void completeOnCreate() {
|
||||
completeOnCreateCalled = true;
|
||||
|
||||
shortcutHelper = new ShortcutHelper(this);
|
||||
|
||||
UiHelper.setLocale(this);
|
||||
|
||||
// Bind to the computer manager service
|
||||
bindService(new Intent(PcView.this, ComputerManagerService.class), serviceConnection,
|
||||
Service.BIND_AUTO_CREATE);
|
||||
|
||||
pcGridAdapter = new PcGridAdapter(this, PreferenceConfiguration.readPreferences(this));
|
||||
|
||||
initializeViews();
|
||||
}
|
||||
|
||||
private void startComputerUpdates() {
|
||||
// Only allow polling to start if we're bound to CMS, polling is not already running,
|
||||
// and our activity is in the foreground.
|
||||
if (managerBinder != null && !runningPolling && inForeground) {
|
||||
freezeUpdates = false;
|
||||
managerBinder.startPolling(new ComputerManagerListener() {
|
||||
@Override
|
||||
public void notifyComputerUpdated(final ComputerDetails details) {
|
||||
if (!freezeUpdates) {
|
||||
PcView.this.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
updateComputer(details);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
runningPolling = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void stopComputerUpdates(boolean wait) {
|
||||
if (managerBinder != null) {
|
||||
if (!runningPolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
freezeUpdates = true;
|
||||
|
||||
managerBinder.stopPolling();
|
||||
|
||||
if (wait) {
|
||||
managerBinder.waitForPollingStopped();
|
||||
}
|
||||
|
||||
runningPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
if (managerBinder != null) {
|
||||
unbindService(serviceConnection);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
|
||||
// Display a decoder crash notification if we've returned after a crash
|
||||
UiHelper.showDecoderCrashDialog(this);
|
||||
|
||||
inForeground = true;
|
||||
startComputerUpdates();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
|
||||
inForeground = false;
|
||||
stopComputerUpdates(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
|
||||
Dialog.closeDialogs();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
|
||||
stopComputerUpdates(false);
|
||||
|
||||
// Call superclass
|
||||
super.onCreateContextMenu(menu, v, menuInfo);
|
||||
|
||||
AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo;
|
||||
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
|
||||
if (computer.details.state == ComputerDetails.State.OFFLINE ||
|
||||
computer.details.state == ComputerDetails.State.UNKNOWN) {
|
||||
menu.add(Menu.NONE, WOL_ID, 1, getResources().getString(R.string.pcview_menu_send_wol));
|
||||
}
|
||||
else if (computer.details.pairState != PairState.PAIRED) {
|
||||
menu.add(Menu.NONE, PAIR_ID, 1, getResources().getString(R.string.pcview_menu_pair_pc));
|
||||
}
|
||||
else {
|
||||
if (computer.details.runningGameId != 0) {
|
||||
menu.add(Menu.NONE, RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume));
|
||||
menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit));
|
||||
}
|
||||
|
||||
menu.add(Menu.NONE, FULL_APP_LIST_ID, 4, getResources().getString(R.string.pcview_menu_app_list));
|
||||
}
|
||||
|
||||
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
|
||||
public void onContextMenuClosed(Menu menu) {
|
||||
// For some reason, this gets called again _after_ onPause() is called on this activity.
|
||||
// startComputerUpdates() manages this and won't actual start polling until the activity
|
||||
// returns to the foreground.
|
||||
startComputerUpdates();
|
||||
}
|
||||
|
||||
private void doPair(final ComputerDetails computer) {
|
||||
if (computer.state == ComputerDetails.State.OFFLINE ||
|
||||
ServerHelper.getCurrentAddressFromComputer(computer) == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
if (computer.runningGameId != 0) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.pair_pc_ingame), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
if (managerBinder == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.pairing), Toast.LENGTH_SHORT).show();
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
NvHTTP httpConn;
|
||||
String message;
|
||||
boolean success = false;
|
||||
try {
|
||||
// Stop updates and wait while pairing
|
||||
stopComputerUpdates(true);
|
||||
|
||||
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
||||
managerBinder.getUniqueId(),
|
||||
computer.serverCert,
|
||||
PlatformBinding.getCryptoProvider(PcView.this));
|
||||
if (httpConn.getPairState() == PairState.PAIRED) {
|
||||
// Don't display any toast, but open the app list
|
||||
message = null;
|
||||
success = true;
|
||||
}
|
||||
else {
|
||||
final String pinStr = PairingManager.generatePinString();
|
||||
|
||||
// Spin the dialog off in a thread because it blocks
|
||||
Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title),
|
||||
getResources().getString(R.string.pair_pairing_msg)+" "+pinStr, false);
|
||||
|
||||
PairingManager pm = httpConn.getPairingManager();
|
||||
|
||||
PairState pairState = pm.pair(httpConn.getServerInfo(), pinStr);
|
||||
if (pairState == PairState.PIN_WRONG) {
|
||||
message = getResources().getString(R.string.pair_incorrect_pin);
|
||||
}
|
||||
else if (pairState == PairState.FAILED) {
|
||||
message = getResources().getString(R.string.pair_fail);
|
||||
}
|
||||
else if (pairState == PairState.ALREADY_IN_PROGRESS) {
|
||||
message = getResources().getString(R.string.pair_already_in_progress);
|
||||
}
|
||||
else if (pairState == PairState.PAIRED) {
|
||||
// Just navigate to the app view without displaying a toast
|
||||
message = null;
|
||||
success = true;
|
||||
|
||||
// Pin this certificate for later HTTPS use
|
||||
managerBinder.getComputer(computer.uuid).serverCert = pm.getPairedCert();
|
||||
|
||||
// Invalidate reachability information after pairing to force
|
||||
// a refresh before reading pair state again
|
||||
managerBinder.invalidateStateForComputer(computer.uuid);
|
||||
}
|
||||
else {
|
||||
// Should be no other values
|
||||
message = null;
|
||||
}
|
||||
}
|
||||
} catch (UnknownHostException e) {
|
||||
message = getResources().getString(R.string.error_unknown_host);
|
||||
} catch (FileNotFoundException e) {
|
||||
message = getResources().getString(R.string.error_404);
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
e.printStackTrace();
|
||||
message = e.getMessage();
|
||||
}
|
||||
|
||||
Dialog.closeDialogs();
|
||||
|
||||
final String toastMessage = message;
|
||||
final boolean toastSuccess = success;
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (toastMessage != null) {
|
||||
Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
if (toastSuccess) {
|
||||
// Open the app list after a successful pairing attempt
|
||||
doAppList(computer, true, false);
|
||||
}
|
||||
else {
|
||||
// Start polling again if we're still in the foreground
|
||||
startComputerUpdates();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void doWakeOnLan(final ComputerDetails computer) {
|
||||
if (computer.state == ComputerDetails.State.ONLINE) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.wol_pc_online), Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (computer.macAddress == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.wol_no_mac), Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
String message;
|
||||
try {
|
||||
WakeOnLanSender.sendWolPacket(computer);
|
||||
message = getResources().getString(R.string.wol_waking_msg);
|
||||
} catch (IOException e) {
|
||||
message = getResources().getString(R.string.wol_fail);
|
||||
}
|
||||
|
||||
final String toastMessage = message;
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void doUnpair(final ComputerDetails computer) {
|
||||
if (computer.state == ComputerDetails.State.OFFLINE ||
|
||||
ServerHelper.getCurrentAddressFromComputer(computer) == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
if (managerBinder == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.unpairing), Toast.LENGTH_SHORT).show();
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
NvHTTP httpConn;
|
||||
String message;
|
||||
try {
|
||||
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
||||
managerBinder.getUniqueId(),
|
||||
computer.serverCert,
|
||||
PlatformBinding.getCryptoProvider(PcView.this));
|
||||
if (httpConn.getPairState() == PairingManager.PairState.PAIRED) {
|
||||
httpConn.unpair();
|
||||
if (httpConn.getPairState() == PairingManager.PairState.NOT_PAIRED) {
|
||||
message = getResources().getString(R.string.unpair_success);
|
||||
}
|
||||
else {
|
||||
message = getResources().getString(R.string.unpair_fail);
|
||||
}
|
||||
}
|
||||
else {
|
||||
message = getResources().getString(R.string.unpair_error);
|
||||
}
|
||||
} catch (UnknownHostException e) {
|
||||
message = getResources().getString(R.string.error_unknown_host);
|
||||
} catch (FileNotFoundException e) {
|
||||
message = getResources().getString(R.string.error_404);
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
message = e.getMessage();
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
final String toastMessage = message;
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void doAppList(ComputerDetails computer, boolean newlyPaired, boolean showHiddenGames) {
|
||||
if (computer.state == ComputerDetails.State.OFFLINE) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
if (managerBinder == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
Intent i = new Intent(this, AppView.class);
|
||||
i.putExtra(AppView.NAME_EXTRA, computer.name);
|
||||
i.putExtra(AppView.UUID_EXTRA, computer.uuid);
|
||||
i.putExtra(AppView.NEW_PAIR_EXTRA, newlyPaired);
|
||||
i.putExtra(AppView.SHOW_HIDDEN_APPS_EXTRA, showHiddenGames);
|
||||
startActivity(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onContextItemSelected(MenuItem item) {
|
||||
AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo();
|
||||
final ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position);
|
||||
switch (item.getItemId()) {
|
||||
case PAIR_ID:
|
||||
doPair(computer.details);
|
||||
return true;
|
||||
|
||||
case UNPAIR_ID:
|
||||
doUnpair(computer.details);
|
||||
return true;
|
||||
|
||||
case WOL_ID:
|
||||
doWakeOnLan(computer.details);
|
||||
return true;
|
||||
|
||||
case DELETE_ID:
|
||||
if (ActivityManager.isUserAMonkey()) {
|
||||
LimeLog.info("Ignoring delete PC request from monkey");
|
||||
return true;
|
||||
}
|
||||
UiHelper.displayDeletePcConfirmationDialog(this, computer.details, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (managerBinder == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
removeComputer(computer.details);
|
||||
}
|
||||
}, null);
|
||||
return true;
|
||||
|
||||
case FULL_APP_LIST_ID:
|
||||
doAppList(computer.details, false, true);
|
||||
return true;
|
||||
|
||||
case RESUME_ID:
|
||||
if (managerBinder == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
|
||||
return true;
|
||||
}
|
||||
|
||||
ServerHelper.doStart(this, new NvApp("app", computer.details.runningGameId, false), computer.details, managerBinder);
|
||||
return true;
|
||||
|
||||
case QUIT_ID:
|
||||
if (managerBinder == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Display a confirmation dialog first
|
||||
UiHelper.displayQuitConfirmationDialog(this, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
ServerHelper.doQuit(PcView.this, computer.details,
|
||||
new NvApp("app", 0, false), managerBinder, null);
|
||||
}
|
||||
}, null);
|
||||
return true;
|
||||
|
||||
case VIEW_DETAILS_ID:
|
||||
Dialog.displayDialog(PcView.this, getResources().getString(R.string.title_details), computer.details.toString(), false);
|
||||
return true;
|
||||
|
||||
case TEST_NETWORK_ID:
|
||||
ServerHelper.doNetworkTest(PcView.this);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return super.onContextItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeComputer(ComputerDetails details) {
|
||||
managerBinder.removeComputer(details);
|
||||
|
||||
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++) {
|
||||
ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i);
|
||||
|
||||
if (details.equals(computer.details)) {
|
||||
// Disable or delete shortcuts referencing this PC
|
||||
shortcutHelper.disableComputerShortcut(details,
|
||||
getResources().getString(R.string.scut_deleted_pc));
|
||||
|
||||
pcGridAdapter.removeComputer(computer);
|
||||
pcGridAdapter.notifyDataSetChanged();
|
||||
|
||||
if (pcGridAdapter.getCount() == 0) {
|
||||
// Show the "Discovery in progress" view
|
||||
noPcFoundLayout.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateComputer(ComputerDetails details) {
|
||||
ComputerObject existingEntry = null;
|
||||
|
||||
for (int i = 0; i < pcGridAdapter.getCount(); i++) {
|
||||
ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i);
|
||||
|
||||
// Check if this is the same computer
|
||||
if (details.uuid.equals(computer.details.uuid)) {
|
||||
existingEntry = computer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a launcher shortcut for this PC
|
||||
if (details.pairState == PairState.PAIRED) {
|
||||
shortcutHelper.createAppViewShortcutForOnlineHost(details);
|
||||
}
|
||||
|
||||
if (existingEntry != null) {
|
||||
// Replace the information in the existing entry
|
||||
existingEntry.details = details;
|
||||
}
|
||||
else {
|
||||
// Add a new entry
|
||||
pcGridAdapter.addComputer(new ComputerObject(details));
|
||||
|
||||
// Remove the "Discovery in progress" view
|
||||
noPcFoundLayout.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
// Notify the view that the data has changed
|
||||
pcGridAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getAdapterFragmentLayoutId() {
|
||||
return R.layout.pc_grid_view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveAbsListView(AbsListView listView) {
|
||||
listView.setAdapter(pcGridAdapter);
|
||||
listView.setOnItemClickListener(new OnItemClickListener() {
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> arg0, View arg1, int pos,
|
||||
long id) {
|
||||
ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(pos);
|
||||
if (computer.details.state == ComputerDetails.State.UNKNOWN ||
|
||||
computer.details.state == ComputerDetails.State.OFFLINE) {
|
||||
// Open the context menu if a PC is offline or refreshing
|
||||
openContextMenu(arg1);
|
||||
} else if (computer.details.pairState != PairState.PAIRED) {
|
||||
// Pair an unpaired machine by default
|
||||
doPair(computer.details);
|
||||
} else {
|
||||
doAppList(computer.details, false, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
UiHelper.applyStatusBarPadding(listView);
|
||||
registerForContextMenu(listView);
|
||||
}
|
||||
|
||||
public static class ComputerObject {
|
||||
public ComputerDetails details;
|
||||
|
||||
public ComputerObject(ComputerDetails details) {
|
||||
if (details == null) {
|
||||
throw new IllegalArgumentException("details must not be null");
|
||||
}
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return details.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.limelight;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.UriMatcher;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import com.limelight.grid.assets.DiskAssetLoader;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.List;
|
||||
|
||||
public class PosterContentProvider extends ContentProvider {
|
||||
|
||||
|
||||
public static final String AUTHORITY = "poster." + BuildConfig.APPLICATION_ID;
|
||||
public static final String PNG_MIME_TYPE = "image/png";
|
||||
public static final int APP_ID_PATH_INDEX = 2;
|
||||
public static final int COMPUTER_UUID_PATH_INDEX = 1;
|
||||
private DiskAssetLoader mDiskAssetLoader;
|
||||
|
||||
private static final UriMatcher sUriMatcher;
|
||||
private static final String BOXART_PATH = "boxart";
|
||||
private static final int BOXART_URI_ID = 1;
|
||||
|
||||
static {
|
||||
sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||
sUriMatcher.addURI(AUTHORITY, BOXART_PATH, BOXART_URI_ID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
int match = sUriMatcher.match(uri);
|
||||
if (match == BOXART_URI_ID) {
|
||||
return openBoxArtFile(uri, mode);
|
||||
}
|
||||
return openBoxArtFile(uri, mode);
|
||||
|
||||
}
|
||||
|
||||
public ParcelFileDescriptor openBoxArtFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
if (!"r".equals(mode)) {
|
||||
throw new UnsupportedOperationException("This provider is only for read mode");
|
||||
}
|
||||
|
||||
List<String> segments = uri.getPathSegments();
|
||||
if (segments.size() != 3) {
|
||||
throw new FileNotFoundException();
|
||||
}
|
||||
String appId = segments.get(APP_ID_PATH_INDEX);
|
||||
String uuid = segments.get(COMPUTER_UUID_PATH_INDEX);
|
||||
File file = mDiskAssetLoader.getFile(uuid, Integer.parseInt(appId));
|
||||
if (file.exists()) {
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
throw new FileNotFoundException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("This provider is only for read mode");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
return PNG_MIME_TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("This provider is only for read mode");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
mDiskAssetLoader = new DiskAssetLoader(getContext());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection,
|
||||
String[] selectionArgs, String sortOrder) {
|
||||
throw new UnsupportedOperationException("This provider doesn't support query");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection,
|
||||
String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("This provider is support read only");
|
||||
}
|
||||
|
||||
|
||||
public static Uri createBoxArtUri(String uuid, String appId) {
|
||||
return new Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_CONTENT)
|
||||
.authority(AUTHORITY)
|
||||
.appendPath(BOXART_PATH)
|
||||
.appendPath(uuid)
|
||||
.appendPath(appId)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.limelight;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Service;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
|
||||
import com.limelight.computers.ComputerManagerListener;
|
||||
import com.limelight.computers.ComputerManagerService;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
import com.limelight.nvstream.http.PairingManager;
|
||||
import com.limelight.utils.Dialog;
|
||||
import com.limelight.utils.ServerHelper;
|
||||
import com.limelight.utils.SpinnerDialog;
|
||||
import com.limelight.utils.UiHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
|
||||
public class ShortcutTrampoline extends Activity {
|
||||
private String uuidString;
|
||||
private NvApp app;
|
||||
private ArrayList<Intent> intentStack = new ArrayList<>();
|
||||
|
||||
private ComputerDetails computer;
|
||||
private SpinnerDialog blockingLoadSpinner;
|
||||
|
||||
private ComputerManagerService.ComputerManagerBinder managerBinder;
|
||||
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
||||
public void onServiceConnected(ComponentName className, IBinder binder) {
|
||||
final ComputerManagerService.ComputerManagerBinder localBinder =
|
||||
((ComputerManagerService.ComputerManagerBinder)binder);
|
||||
|
||||
// Wait in a separate thread to avoid stalling the UI
|
||||
new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Wait for the binder to be ready
|
||||
localBinder.waitForReady();
|
||||
|
||||
// Now make the binder visible
|
||||
managerBinder = localBinder;
|
||||
|
||||
// Get the computer object
|
||||
computer = managerBinder.getComputer(uuidString);
|
||||
|
||||
if (computer == null) {
|
||||
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||
getResources().getString(R.string.conn_error_title),
|
||||
getResources().getString(R.string.scut_pc_not_found),
|
||||
true);
|
||||
|
||||
if (blockingLoadSpinner != null) {
|
||||
blockingLoadSpinner.dismiss();
|
||||
blockingLoadSpinner = null;
|
||||
}
|
||||
|
||||
if (managerBinder != null) {
|
||||
unbindService(serviceConnection);
|
||||
managerBinder = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Force CMS to repoll this machine
|
||||
managerBinder.invalidateStateForComputer(computer.uuid);
|
||||
|
||||
// Start polling
|
||||
managerBinder.startPolling(new ComputerManagerListener() {
|
||||
@Override
|
||||
public void notifyComputerUpdated(final ComputerDetails details) {
|
||||
// Don't care about other computers
|
||||
if (!details.uuid.equalsIgnoreCase(uuidString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.state != ComputerDetails.State.UNKNOWN) {
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Stop showing the spinner
|
||||
if (blockingLoadSpinner != null) {
|
||||
blockingLoadSpinner.dismiss();
|
||||
blockingLoadSpinner = null;
|
||||
}
|
||||
|
||||
// If the managerBinder was destroyed before this callback,
|
||||
// just finish the activity.
|
||||
if (managerBinder == null) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.state == ComputerDetails.State.ONLINE && details.pairState == PairingManager.PairState.PAIRED) {
|
||||
|
||||
// Launch game if provided app ID, otherwise launch app view
|
||||
if (app != null) {
|
||||
if (details.runningGameId == 0 || details.runningGameId == app.getAppId()) {
|
||||
intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder));
|
||||
|
||||
// Close this activity
|
||||
finish();
|
||||
|
||||
// Now start the activities
|
||||
startActivities(intentStack.toArray(new Intent[]{}));
|
||||
} else {
|
||||
// Create the start intent immediately, so we can safely unbind the managerBinder
|
||||
// below before we return.
|
||||
final Intent startIntent = ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder);
|
||||
|
||||
UiHelper.displayQuitConfirmationDialog(ShortcutTrampoline.this, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
intentStack.add(startIntent);
|
||||
|
||||
// Close this activity
|
||||
finish();
|
||||
|
||||
// Now start the activities
|
||||
startActivities(intentStack.toArray(new Intent[]{}));
|
||||
}
|
||||
}, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Close this activity
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Close this activity
|
||||
finish();
|
||||
|
||||
// Add the PC view at the back (and clear the task)
|
||||
Intent i;
|
||||
i = new Intent(ShortcutTrampoline.this, PcView.class);
|
||||
i.setAction(Intent.ACTION_MAIN);
|
||||
i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
intentStack.add(i);
|
||||
|
||||
// Take this intent's data and create an intent to start the app view
|
||||
i = new Intent(getIntent());
|
||||
i.setClass(ShortcutTrampoline.this, AppView.class);
|
||||
intentStack.add(i);
|
||||
|
||||
// If a game is running, we'll make the stream the top level activity
|
||||
if (details.runningGameId != 0) {
|
||||
intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this,
|
||||
new NvApp(null, details.runningGameId, false), details, managerBinder));
|
||||
}
|
||||
|
||||
// Now start the activities
|
||||
startActivities(intentStack.toArray(new Intent[]{}));
|
||||
}
|
||||
|
||||
}
|
||||
else if (details.state == ComputerDetails.State.OFFLINE) {
|
||||
// Computer offline - display an error dialog
|
||||
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||
getResources().getString(R.string.conn_error_title),
|
||||
getResources().getString(R.string.error_pc_offline),
|
||||
true);
|
||||
} else if (details.pairState != PairingManager.PairState.PAIRED) {
|
||||
// Computer not paired - display an error dialog
|
||||
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||
getResources().getString(R.string.conn_error_title),
|
||||
getResources().getString(R.string.scut_not_paired),
|
||||
true);
|
||||
}
|
||||
|
||||
// We don't want any more callbacks from now on, so go ahead
|
||||
// and unbind from the service
|
||||
if (managerBinder != null) {
|
||||
managerBinder.stopPolling();
|
||||
unbindService(serviceConnection);
|
||||
managerBinder = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
public void onServiceDisconnected(ComponentName className) {
|
||||
managerBinder = null;
|
||||
}
|
||||
};
|
||||
|
||||
protected boolean validateInput(String uuidString, String appIdString) {
|
||||
// Validate UUID
|
||||
if (uuidString == null) {
|
||||
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||
getResources().getString(R.string.conn_error_title),
|
||||
getResources().getString(R.string.scut_invalid_uuid),
|
||||
true);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
UUID.fromString(uuidString);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
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)
|
||||
if (appIdString != null && !appIdString.isEmpty()) {
|
||||
try {
|
||||
Integer.parseInt(appIdString);
|
||||
} catch (NumberFormatException ex) {
|
||||
Dialog.displayDialog(ShortcutTrampoline.this,
|
||||
getResources().getString(R.string.conn_error_title),
|
||||
getResources().getString(R.string.scut_invalid_app_id),
|
||||
true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
UiHelper.notifyNewRootView(this);
|
||||
|
||||
String appIdString = getIntent().getStringExtra(Game.EXTRA_APP_ID);
|
||||
uuidString = getIntent().getStringExtra(AppView.UUID_EXTRA);
|
||||
|
||||
if (validateInput(uuidString, appIdString)) {
|
||||
if (appIdString != null && !appIdString.isEmpty()) {
|
||||
app = new NvApp(getIntent().getStringExtra(Game.EXTRA_APP_NAME),
|
||||
Integer.parseInt(appIdString),
|
||||
getIntent().getBooleanExtra(Game.EXTRA_APP_HDR, false));
|
||||
}
|
||||
|
||||
// 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
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
|
||||
if (blockingLoadSpinner != null) {
|
||||
blockingLoadSpinner.dismiss();
|
||||
blockingLoadSpinner = null;
|
||||
}
|
||||
|
||||
Dialog.closeDialogs();
|
||||
|
||||
if (managerBinder != null) {
|
||||
managerBinder.stopPolling();
|
||||
unbindService(serviceConnection);
|
||||
managerBinder = null;
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -8,17 +8,17 @@ import com.limelight.nvstream.av.audio.AudioRenderer;
|
||||
import com.limelight.nvstream.http.LimelightCryptoProvider;
|
||||
|
||||
public class PlatformBinding {
|
||||
public static String getDeviceName() {
|
||||
String deviceName = android.os.Build.MODEL;
|
||||
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) {
|
||||
return new AndroidCryptoProvider(c);
|
||||
}
|
||||
}
|
||||
|
||||
public static AudioRenderer getAudioRenderer() {
|
||||
return new AndroidAudioRenderer();
|
||||
}
|
||||
|
||||
public static LimelightCryptoProvider getCryptoProvider(Context c) {
|
||||
return new AndroidCryptoProvider(c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.limelight.binding.audio;
|
||||
|
||||
import android.media.AudioAttributes;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioTrack;
|
||||
import android.os.Build;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.nvstream.av.audio.AudioRenderer;
|
||||
import com.limelight.nvstream.jni.MoonBridge;
|
||||
|
||||
public class AndroidAudioRenderer implements AudioRenderer {
|
||||
|
||||
private AudioTrack track;
|
||||
|
||||
private AudioTrack createAudioTrack(int channelConfig, int sampleRate, int bufferSize, boolean lowLatency) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
return new AudioTrack(AudioManager.STREAM_MUSIC,
|
||||
sampleRate,
|
||||
channelConfig,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM);
|
||||
}
|
||||
else {
|
||||
AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_GAME);
|
||||
AudioFormat format = new AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame) {
|
||||
int channelConfig;
|
||||
int bytesPerFrame;
|
||||
|
||||
switch (audioConfiguration.channelCount)
|
||||
{
|
||||
case 2:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
|
||||
break;
|
||||
case 4:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_QUAD;
|
||||
break;
|
||||
case 6:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
|
||||
break;
|
||||
case 8:
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
// 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
|
||||
// in 5.0, so just hardcode the constant so we can work on Lollipop.
|
||||
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;
|
||||
default:
|
||||
LimeLog.severe("Decoder returned unhandled channel count");
|
||||
return -1;
|
||||
}
|
||||
|
||||
LimeLog.info("Audio channel config: "+String.format("0x%X", channelConfig));
|
||||
|
||||
bytesPerFrame = audioConfiguration.channelCount * samplesPerFrame * 2;
|
||||
|
||||
// We're not supposed to request less than the minimum
|
||||
// buffer size for our buffer, but it appears that we can
|
||||
// do this on many devices and it lowers audio latency.
|
||||
// We'll try the small buffer size first and if it fails,
|
||||
// use the recommended larger buffer size.
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
boolean lowLatency;
|
||||
int bufferSize;
|
||||
|
||||
// We will try:
|
||||
// 1) Small buffer, low latency mode
|
||||
// 2) Large buffer, low latency mode
|
||||
// 3) Small buffer, standard mode
|
||||
// 4) Large buffer, standard mode
|
||||
|
||||
switch (i) {
|
||||
case 0:
|
||||
case 1:
|
||||
lowLatency = true;
|
||||
break;
|
||||
case 2:
|
||||
case 3:
|
||||
lowLatency = false;
|
||||
break;
|
||||
default:
|
||||
// Unreachable
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
|
||||
switch (i) {
|
||||
case 0:
|
||||
case 2:
|
||||
bufferSize = bytesPerFrame * 2;
|
||||
break;
|
||||
|
||||
case 1:
|
||||
case 3:
|
||||
// Try the larger buffer size
|
||||
bufferSize = Math.max(AudioTrack.getMinBufferSize(sampleRate,
|
||||
channelConfig,
|
||||
AudioFormat.ENCODING_PCM_16BIT),
|
||||
bytesPerFrame * 2);
|
||||
|
||||
// Round to next frame
|
||||
bufferSize = (((bufferSize + (bytesPerFrame - 1)) / bytesPerFrame) * bytesPerFrame);
|
||||
break;
|
||||
default:
|
||||
// Unreachable
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
|
||||
// Skip low latency options if hardware sample rate doesn't match the content
|
||||
if (AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC) != sampleRate && lowLatency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
track = createAudioTrack(channelConfig, sampleRate, bufferSize, lowLatency);
|
||||
track.play();
|
||||
|
||||
// Successfully created working AudioTrack. We're done here.
|
||||
LimeLog.info("Audio track configuration: "+bufferSize+" "+lowLatency);
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
// Try to release the AudioTrack if we got far enough
|
||||
e.printStackTrace();
|
||||
try {
|
||||
if (track != null) {
|
||||
track.release();
|
||||
track = null;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (track == null) {
|
||||
// Couldn't create any audio track for playback
|
||||
return -2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playDecodedAudio(short[] audioData) {
|
||||
// Only queue up to 40 ms of pending audio data in addition to what AudioTrack is buffering for us.
|
||||
if (MoonBridge.getPendingAudioDuration() < 40) {
|
||||
// This will block until the write is completed. That can cause a backlog
|
||||
// of pending audio data, so we do the above check to be able to bound
|
||||
// latency at 40 ms in that situation.
|
||||
track.write(audioData, 0, audioData.length);
|
||||
}
|
||||
else {
|
||||
LimeLog.info("Too much pending audio data: " + MoonBridge.getPendingAudioDuration() +" ms");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {}
|
||||
|
||||
@Override
|
||||
public void stop() {}
|
||||
|
||||
@Override
|
||||
public void cleanup() {
|
||||
// Immediately drop all pending data
|
||||
track.pause();
|
||||
track.flush();
|
||||
|
||||
track.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.limelight.binding.crypto;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.math.BigInteger;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.Provider;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x500.X500NameBuilder;
|
||||
import org.bouncycastle.asn1.x500.style.BCStyle;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.util.Base64;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.nvstream.http.LimelightCryptoProvider;
|
||||
|
||||
public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
||||
|
||||
private final File certFile;
|
||||
private final File keyFile;
|
||||
|
||||
private X509Certificate cert;
|
||||
private RSAPrivateKey key;
|
||||
private byte[] pemCertBytes;
|
||||
|
||||
private static final Object globalCryptoLock = new Object();
|
||||
|
||||
private static final Provider bcProvider = new BouncyCastleProvider();
|
||||
|
||||
public AndroidCryptoProvider(Context c) {
|
||||
String dataPath = c.getFilesDir().getAbsolutePath();
|
||||
|
||||
certFile = new File(dataPath + File.separator + "client.crt");
|
||||
keyFile = new File(dataPath + File.separator + "client.key");
|
||||
}
|
||||
|
||||
private byte[] loadFileToBytes(File f) {
|
||||
if (!f.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
FileInputStream fin = new FileInputStream(f);
|
||||
byte[] fileData = new byte[(int) f.length()];
|
||||
if (fin.read(fileData) != f.length()) {
|
||||
// Failed to read
|
||||
fileData = null;
|
||||
}
|
||||
fin.close();
|
||||
return fileData;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean loadCertKeyPair() {
|
||||
byte[] certBytes = loadFileToBytes(certFile);
|
||||
byte[] keyBytes = loadFileToBytes(keyFile);
|
||||
|
||||
// If either file was missing, we definitely can't succeed
|
||||
if (certBytes == null || keyBytes == null) {
|
||||
LimeLog.info("Missing cert or key; need to generate a new one");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
CertificateFactory certFactory = CertificateFactory.getInstance("X.509", bcProvider);
|
||||
cert = (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
pemCertBytes = certBytes;
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA", bcProvider);
|
||||
key = (RSAPrivateKey) keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
||||
} catch (CertificateException e) {
|
||||
// May happen if the cert is corrupt
|
||||
LimeLog.warning("Corrupted certificate");
|
||||
return false;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
// May happen if the key is corrupt
|
||||
LimeLog.warning("Corrupted key");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@SuppressLint("TrulyRandom")
|
||||
private boolean generateCertKeyPair() {
|
||||
byte[] snBytes = new byte[8];
|
||||
new SecureRandom().nextBytes(snBytes);
|
||||
|
||||
KeyPair keyPair;
|
||||
try {
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", bcProvider);
|
||||
keyPairGenerator.initialize(2048);
|
||||
keyPair = keyPairGenerator.generateKeyPair();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
Date now = new Date();
|
||||
|
||||
// Expires in 20 years
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTime(now);
|
||||
calendar.add(Calendar.YEAR, 20);
|
||||
Date expirationDate = calendar.getTime();
|
||||
|
||||
BigInteger serial = new BigInteger(snBytes).abs();
|
||||
|
||||
X500NameBuilder nameBuilder = new X500NameBuilder(BCStyle.INSTANCE);
|
||||
nameBuilder.addRDN(BCStyle.CN, "NVIDIA GameStream Client");
|
||||
X500Name name = nameBuilder.build();
|
||||
|
||||
X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder(name, serial, now, expirationDate, Locale.ENGLISH, name,
|
||||
SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded()));
|
||||
|
||||
try {
|
||||
ContentSigner sigGen = new JcaContentSignerBuilder("SHA256withRSA").setProvider(bcProvider).build(keyPair.getPrivate());
|
||||
cert = new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate(certBuilder.build(sigGen));
|
||||
key = (RSAPrivateKey) keyPair.getPrivate();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
LimeLog.info("Generated a new key pair");
|
||||
|
||||
// Save the resulting pair
|
||||
saveCertKeyPair();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void saveCertKeyPair() {
|
||||
try {
|
||||
FileOutputStream certOut = new FileOutputStream(certFile);
|
||||
FileOutputStream keyOut = new FileOutputStream(keyFile);
|
||||
|
||||
// Write the certificate in OpenSSL PEM format (important for the server)
|
||||
StringWriter strWriter = new StringWriter();
|
||||
JcaPEMWriter pemWriter = new JcaPEMWriter(strWriter);
|
||||
pemWriter.writeObject(cert);
|
||||
pemWriter.close();
|
||||
|
||||
// Line endings MUST be UNIX for the PC to accept the cert properly
|
||||
OutputStreamWriter certWriter = new OutputStreamWriter(certOut);
|
||||
String pemStr = strWriter.getBuffer().toString();
|
||||
for (int i = 0; i < pemStr.length(); i++) {
|
||||
char c = pemStr.charAt(i);
|
||||
if (c != '\r')
|
||||
certWriter.append(c);
|
||||
}
|
||||
certWriter.close();
|
||||
|
||||
// Write the private out in PKCS8 format
|
||||
keyOut.write(key.getEncoded());
|
||||
|
||||
certOut.close();
|
||||
keyOut.close();
|
||||
|
||||
LimeLog.info("Saved generated key pair to disk");
|
||||
} catch (IOException e) {
|
||||
// This isn't good because it means we'll have
|
||||
// to re-pair next time
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public X509Certificate getClientCertificate() {
|
||||
// Use a lock here to ensure only one guy will be generating or loading
|
||||
// the certificate and key at a time
|
||||
synchronized (globalCryptoLock) {
|
||||
// Return a loaded cert if we have one
|
||||
if (cert != null) {
|
||||
return cert;
|
||||
}
|
||||
|
||||
// No loaded cert yet, let's see if we have one on disk
|
||||
if (loadCertKeyPair()) {
|
||||
// Got one
|
||||
return cert;
|
||||
}
|
||||
|
||||
// Try to generate a new key pair
|
||||
if (!generateCertKeyPair()) {
|
||||
// Failed
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load the generated pair
|
||||
loadCertKeyPair();
|
||||
return cert;
|
||||
}
|
||||
}
|
||||
|
||||
public RSAPrivateKey getClientPrivateKey() {
|
||||
// Use a lock here to ensure only one guy will be generating or loading
|
||||
// the certificate and key at a time
|
||||
synchronized (globalCryptoLock) {
|
||||
// Return a loaded key if we have one
|
||||
if (key != null) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// No loaded key yet, let's see if we have one on disk
|
||||
if (loadCertKeyPair()) {
|
||||
// Got one
|
||||
return key;
|
||||
}
|
||||
|
||||
// Try to generate a new key pair
|
||||
if (!generateCertKeyPair()) {
|
||||
// Failed
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load the generated pair
|
||||
loadCertKeyPair();
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getPemEncodedClientCertificate() {
|
||||
synchronized (globalCryptoLock) {
|
||||
// Call our helper function to do the cert loading/generation for us
|
||||
getClientCertificate();
|
||||
|
||||
// Return a cached value if we have it
|
||||
return pemCertBytes;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String encodeBase64String(byte[] data) {
|
||||
return Base64.encodeToString(data, Base64.NO_WRAP);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
package com.limelight.binding.input;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
|
||||
/**
|
||||
* Class to translate a Android key code into the codes GFE is expecting
|
||||
* @author Diego Waxemberg
|
||||
* @author Cameron Gutman
|
||||
*/
|
||||
public class KeyboardTranslator {
|
||||
|
||||
/**
|
||||
* GFE's prefix for every key code
|
||||
*/
|
||||
private static final short KEY_PREFIX = (short) 0x80;
|
||||
|
||||
public static final int VK_0 = 48;
|
||||
public static final int VK_9 = 57;
|
||||
public static final int VK_A = 65;
|
||||
public static final int VK_Z = 90;
|
||||
public static final int VK_NUMPAD0 = 96;
|
||||
public static final int VK_BACK_SLASH = 92;
|
||||
public static final int VK_CAPS_LOCK = 20;
|
||||
public static final int VK_CLEAR = 12;
|
||||
public static final int VK_COMMA = 44;
|
||||
public static final int VK_BACK_SPACE = 8;
|
||||
public static final int VK_EQUALS = 61;
|
||||
public static final int VK_ESCAPE = 27;
|
||||
public static final int VK_F1 = 112;
|
||||
public static final int VK_END = 35;
|
||||
public static final int VK_HOME = 36;
|
||||
public static final int VK_NUM_LOCK = 144;
|
||||
public static final int VK_PAGE_UP = 33;
|
||||
public static final int VK_PAGE_DOWN = 34;
|
||||
public static final int VK_PLUS = 521;
|
||||
public static final int VK_CLOSE_BRACKET = 93;
|
||||
public static final int VK_SCROLL_LOCK = 145;
|
||||
public static final int VK_SEMICOLON = 59;
|
||||
public static final int VK_SLASH = 47;
|
||||
public static final int VK_SPACE = 32;
|
||||
public static final int VK_PRINTSCREEN = 154;
|
||||
public static final int VK_TAB = 9;
|
||||
public static final int VK_LEFT = 37;
|
||||
public static final int VK_RIGHT = 39;
|
||||
public static final int VK_UP = 38;
|
||||
public static final int VK_DOWN = 40;
|
||||
public static final int VK_BACK_QUOTE = 192;
|
||||
public static final int VK_QUOTE = 222;
|
||||
public static final int VK_PAUSE = 19;
|
||||
|
||||
public static boolean needsShift(int keycode) {
|
||||
switch (keycode)
|
||||
{
|
||||
case KeyEvent.KEYCODE_AT:
|
||||
case KeyEvent.KEYCODE_POUND:
|
||||
case KeyEvent.KEYCODE_PLUS:
|
||||
case KeyEvent.KEYCODE_STAR:
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the given keycode and returns the GFE keycode
|
||||
* @param keycode the code to be translated
|
||||
* @return a GFE keycode for the given keycode
|
||||
*/
|
||||
public static short translate(int keycode) {
|
||||
int translated;
|
||||
|
||||
// This is a poor man's mapping between Android key codes
|
||||
// and Windows VK_* codes. For all defined VK_ codes, see:
|
||||
// https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx
|
||||
if (keycode >= KeyEvent.KEYCODE_0 &&
|
||||
keycode <= KeyEvent.KEYCODE_9) {
|
||||
translated = (keycode - KeyEvent.KEYCODE_0) + VK_0;
|
||||
}
|
||||
else if (keycode >= KeyEvent.KEYCODE_A &&
|
||||
keycode <= KeyEvent.KEYCODE_Z) {
|
||||
translated = (keycode - KeyEvent.KEYCODE_A) + VK_A;
|
||||
}
|
||||
else if (keycode >= KeyEvent.KEYCODE_NUMPAD_0 &&
|
||||
keycode <= KeyEvent.KEYCODE_NUMPAD_9) {
|
||||
translated = (keycode - KeyEvent.KEYCODE_NUMPAD_0) + VK_NUMPAD0;
|
||||
}
|
||||
else if (keycode >= KeyEvent.KEYCODE_F1 &&
|
||||
keycode <= KeyEvent.KEYCODE_F12) {
|
||||
translated = (keycode - KeyEvent.KEYCODE_F1) + VK_F1;
|
||||
}
|
||||
else {
|
||||
switch (keycode) {
|
||||
case KeyEvent.KEYCODE_ALT_LEFT:
|
||||
translated = 0xA4;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_ALT_RIGHT:
|
||||
translated = 0xA5;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_BACKSLASH:
|
||||
translated = 0xdc;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_CAPS_LOCK:
|
||||
translated = VK_CAPS_LOCK;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_CLEAR:
|
||||
translated = VK_CLEAR;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_COMMA:
|
||||
translated = 0xbc;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_CTRL_LEFT:
|
||||
translated = 0xA2;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_CTRL_RIGHT:
|
||||
translated = 0xA3;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_DEL:
|
||||
translated = VK_BACK_SPACE;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
translated = 0x0d;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_PLUS:
|
||||
case KeyEvent.KEYCODE_EQUALS:
|
||||
translated = 0xbb;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_ESCAPE:
|
||||
translated = VK_ESCAPE;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_FORWARD_DEL:
|
||||
translated = 0x2e;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_INSERT:
|
||||
translated = 0x2d;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_LEFT_BRACKET:
|
||||
translated = 0xdb;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_META_LEFT:
|
||||
translated = 0x5b;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_META_RIGHT:
|
||||
translated = 0x5c;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_MINUS:
|
||||
translated = 0xbd;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_MOVE_END:
|
||||
translated = VK_END;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_MOVE_HOME:
|
||||
translated = VK_HOME;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_NUM_LOCK:
|
||||
translated = VK_NUM_LOCK;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_PAGE_DOWN:
|
||||
translated = VK_PAGE_DOWN;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_PAGE_UP:
|
||||
translated = VK_PAGE_UP;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_PERIOD:
|
||||
translated = 0xbe;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_RIGHT_BRACKET:
|
||||
translated = 0xdd;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_SCROLL_LOCK:
|
||||
translated = VK_SCROLL_LOCK;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_SEMICOLON:
|
||||
translated = 0xba;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_SHIFT_LEFT:
|
||||
translated = 0xA0;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_SHIFT_RIGHT:
|
||||
translated = 0xA1;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_SLASH:
|
||||
translated = 0xbf;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_SPACE:
|
||||
translated = VK_SPACE;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_SYSRQ:
|
||||
// Android defines this as SysRq/PrntScrn
|
||||
translated = VK_PRINTSCREEN;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_TAB:
|
||||
translated = VK_TAB;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
translated = VK_LEFT;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
translated = VK_RIGHT;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
translated = VK_UP;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
translated = VK_DOWN;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_GRAVE:
|
||||
translated = VK_BACK_QUOTE;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_APOSTROPHE:
|
||||
translated = 0xde;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_BREAK:
|
||||
translated = VK_PAUSE;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_NUMPAD_DIVIDE:
|
||||
translated = 0x6F;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_NUMPAD_MULTIPLY:
|
||||
translated = 0x6A;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_NUMPAD_SUBTRACT:
|
||||
translated = 0x6D;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_NUMPAD_ADD:
|
||||
translated = 0x6B;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_NUMPAD_DOT:
|
||||
translated = 0x6E;
|
||||
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:
|
||||
System.out.println("No key for "+keycode);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return (short) ((KEY_PREFIX << 8) | translated);
|
||||
}
|
||||
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.limelight.binding.input.capture;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Activity;
|
||||
import android.os.Build;
|
||||
import android.view.InputDevice;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
|
||||
|
||||
// We extend AndroidPointerIconCaptureProvider because we want to also get the
|
||||
// pointer icon hiding behavior over our stream view just in case pointer capture
|
||||
// is unavailable on this system (ex: DeX, ChromeOS)
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
public class AndroidNativePointerCaptureProvider extends AndroidPointerIconCaptureProvider {
|
||||
private View targetView;
|
||||
|
||||
public AndroidNativePointerCaptureProvider(Activity activity, View targetView) {
|
||||
super(activity, targetView);
|
||||
this.targetView = targetView;
|
||||
}
|
||||
|
||||
public static boolean isCaptureProviderSupported() {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableCapture() {
|
||||
super.enableCapture();
|
||||
targetView.requestPointerCapture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableCapture() {
|
||||
super.disableCapture();
|
||||
targetView.releasePointerCapture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
||||
return event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getRelativeAxisX(MotionEvent event) {
|
||||
float x = event.getX();
|
||||
for (int i = 0; i < event.getHistorySize(); i++) {
|
||||
x += event.getHistoricalX(i);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getRelativeAxisY(MotionEvent event) {
|
||||
float y = event.getY();
|
||||
for (int i = 0; i < event.getHistorySize(); i++) {
|
||||
y += event.getHistoricalY(i);
|
||||
}
|
||||
return y;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.limelight.binding.input.capture;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.PointerIcon;
|
||||
import android.view.View;
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.N)
|
||||
public class AndroidPointerIconCaptureProvider extends InputCaptureProvider {
|
||||
private View targetView;
|
||||
private Context context;
|
||||
|
||||
public AndroidPointerIconCaptureProvider(Activity activity, View targetView) {
|
||||
this.context = activity;
|
||||
this.targetView = targetView;
|
||||
}
|
||||
|
||||
public static boolean isCaptureProviderSupported() {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableCapture() {
|
||||
super.enableCapture();
|
||||
targetView.setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableCapture() {
|
||||
super.disableCapture();
|
||||
targetView.setPointerIcon(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.limelight.binding.input.capture;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.LimelightBuildProps;
|
||||
import com.limelight.R;
|
||||
import com.limelight.binding.input.evdev.EvdevCaptureProviderShim;
|
||||
import com.limelight.binding.input.evdev.EvdevListener;
|
||||
|
||||
public class InputCaptureManager {
|
||||
public static InputCaptureProvider getInputCaptureProvider(Activity activity, EvdevListener rootListener) {
|
||||
if (AndroidNativePointerCaptureProvider.isCaptureProviderSupported()) {
|
||||
LimeLog.info("Using Android O+ native mouse capture");
|
||||
return new AndroidNativePointerCaptureProvider(activity, activity.findViewById(R.id.surfaceView));
|
||||
}
|
||||
// LineageOS implemented broken NVIDIA capture extensions, so avoid using them on root builds.
|
||||
// See https://github.com/LineageOS/android_frameworks_base/commit/d304f478a023430f4712dbdc3ee69d9ad02cebd3
|
||||
else if (!LimelightBuildProps.ROOT_BUILD && ShieldCaptureProvider.isCaptureProviderSupported()) {
|
||||
LimeLog.info("Using NVIDIA mouse capture extension");
|
||||
return new ShieldCaptureProvider(activity);
|
||||
}
|
||||
else if (EvdevCaptureProviderShim.isCaptureProviderSupported()) {
|
||||
LimeLog.info("Using Evdev mouse capture");
|
||||
return EvdevCaptureProviderShim.createEvdevCaptureProvider(activity, rootListener);
|
||||
}
|
||||
else if (AndroidPointerIconCaptureProvider.isCaptureProviderSupported()) {
|
||||
// Android N's native capture can't capture over system UI elements
|
||||
// so we want to only use it if there's no other option.
|
||||
LimeLog.info("Using Android N+ pointer hiding");
|
||||
return new AndroidPointerIconCaptureProvider(activity, activity.findViewById(R.id.surfaceView));
|
||||
}
|
||||
else {
|
||||
LimeLog.info("Mouse capture not available");
|
||||
return new NullCaptureProvider();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.limelight.binding.input.capture;
|
||||
|
||||
import android.view.MotionEvent;
|
||||
|
||||
public abstract class InputCaptureProvider {
|
||||
protected boolean isCapturing;
|
||||
|
||||
public void enableCapture() {
|
||||
isCapturing = true;
|
||||
}
|
||||
public void disableCapture() {
|
||||
isCapturing = false;
|
||||
}
|
||||
|
||||
public void destroy() {}
|
||||
|
||||
public boolean isCapturingEnabled() {
|
||||
return isCapturing;
|
||||
}
|
||||
|
||||
public boolean isCapturingActive() {
|
||||
return isCapturing;
|
||||
}
|
||||
|
||||
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public float getRelativeAxisX(MotionEvent event) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public float getRelativeAxisY(MotionEvent event) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.limelight.binding.input.capture;
|
||||
|
||||
|
||||
public class NullCaptureProvider extends InputCaptureProvider {}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.limelight.binding.input.capture;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.hardware.input.InputManager;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
// NVIDIA extended the Android input APIs with support for using an attached mouse in relative
|
||||
// mode without having to grab the input device (which requires root). The data comes in the form
|
||||
// of new AXIS_RELATIVE_X and AXIS_RELATIVE_Y constants in the mouse's MotionEvent objects and
|
||||
// a new function, InputManager.setCursorVisibility(), that allows the cursor to be hidden.
|
||||
//
|
||||
// http://docs.nvidia.com/gameworks/index.html#technologies/mobile/game_controller_handling_mouse.htm
|
||||
|
||||
public class ShieldCaptureProvider extends InputCaptureProvider {
|
||||
private static boolean nvExtensionSupported;
|
||||
private static Method methodSetCursorVisibility;
|
||||
private static int AXIS_RELATIVE_X;
|
||||
private static int AXIS_RELATIVE_Y;
|
||||
|
||||
private Context context;
|
||||
|
||||
static {
|
||||
try {
|
||||
methodSetCursorVisibility = InputManager.class.getMethod("setCursorVisibility", boolean.class);
|
||||
|
||||
Field fieldRelX = MotionEvent.class.getField("AXIS_RELATIVE_X");
|
||||
Field fieldRelY = MotionEvent.class.getField("AXIS_RELATIVE_Y");
|
||||
|
||||
AXIS_RELATIVE_X = (Integer) fieldRelX.get(null);
|
||||
AXIS_RELATIVE_Y = (Integer) fieldRelY.get(null);
|
||||
|
||||
nvExtensionSupported = true;
|
||||
} catch (Exception e) {
|
||||
nvExtensionSupported = false;
|
||||
}
|
||||
}
|
||||
|
||||
public ShieldCaptureProvider(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public static boolean isCaptureProviderSupported() {
|
||||
return nvExtensionSupported;
|
||||
}
|
||||
|
||||
private boolean setCursorVisibility(boolean visible) {
|
||||
try {
|
||||
methodSetCursorVisibility.invoke(context.getSystemService(Context.INPUT_SERVICE), visible);
|
||||
return true;
|
||||
} catch (InvocationTargetException e) {
|
||||
e.printStackTrace();
|
||||
} catch (IllegalAccessException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableCapture() {
|
||||
super.enableCapture();
|
||||
setCursorVisibility(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableCapture() {
|
||||
super.disableCapture();
|
||||
setCursorVisibility(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
||||
// All mouse events should use relative axes, even if they are zero. This avoids triggering
|
||||
// cursor jumps if we get an event with no associated motion, like ACTION_DOWN or ACTION_UP.
|
||||
return event.getPointerCount() == 1 && event.getActionIndex() == 0 &&
|
||||
event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getRelativeAxisX(MotionEvent event) {
|
||||
return event.getAxisValue(AXIS_RELATIVE_X);
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getRelativeAxisY(MotionEvent event) {
|
||||
return event.getAxisValue(AXIS_RELATIVE_Y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.limelight.binding.input.driver;
|
||||
|
||||
public abstract class AbstractController {
|
||||
|
||||
private final int deviceId;
|
||||
private final int vendorId;
|
||||
private final int productId;
|
||||
|
||||
private UsbDriverListener listener;
|
||||
|
||||
protected short buttonFlags;
|
||||
protected float leftTrigger, rightTrigger;
|
||||
protected float rightStickX, rightStickY;
|
||||
protected float leftStickX, leftStickY;
|
||||
|
||||
public int getControllerId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public int getVendorId() {
|
||||
return vendorId;
|
||||
}
|
||||
|
||||
public int getProductId() {
|
||||
return productId;
|
||||
}
|
||||
|
||||
protected void setButtonFlag(int buttonFlag, int data) {
|
||||
if (data != 0) {
|
||||
buttonFlags |= buttonFlag;
|
||||
}
|
||||
else {
|
||||
buttonFlags &= ~buttonFlag;
|
||||
}
|
||||
}
|
||||
|
||||
protected void reportInput() {
|
||||
listener.reportControllerState(deviceId, buttonFlags, leftStickX, leftStickY,
|
||||
rightStickX, rightStickY, leftTrigger, rightTrigger);
|
||||
}
|
||||
|
||||
public abstract boolean start();
|
||||
public abstract void stop();
|
||||
|
||||
public AbstractController(int deviceId, UsbDriverListener listener, int vendorId, int productId) {
|
||||
this.deviceId = deviceId;
|
||||
this.listener = listener;
|
||||
this.vendorId = vendorId;
|
||||
this.productId = productId;
|
||||
}
|
||||
|
||||
public abstract void rumble(short lowFreqMotor, short highFreqMotor);
|
||||
|
||||
protected void notifyDeviceRemoved() {
|
||||
listener.deviceRemoved(this);
|
||||
}
|
||||
|
||||
protected void notifyDeviceAdded() {
|
||||
listener.deviceAdded(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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 com.limelight.LimeLog;
|
||||
import com.limelight.binding.video.MediaCodecHelper;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
public abstract class AbstractXboxController extends AbstractController {
|
||||
protected final UsbDevice device;
|
||||
protected final UsbDeviceConnection connection;
|
||||
|
||||
private Thread inputThread;
|
||||
private boolean stopped;
|
||||
|
||||
protected UsbEndpoint inEndpt, outEndpt;
|
||||
|
||||
public AbstractXboxController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
|
||||
super(deviceId, listener, device.getVendorId(), device.getProductId());
|
||||
this.device = device;
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
private Thread createInputThread() {
|
||||
return new Thread() {
|
||||
public void run() {
|
||||
try {
|
||||
// Delay for a moment before reporting the new gamepad and
|
||||
// accepting new input. This allows time for the old InputDevice
|
||||
// to go away before we reclaim its spot. If the old device is still
|
||||
// around when we call notifyDeviceAdded(), we won't be able to claim
|
||||
// the controller number used by the original InputDevice.
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {}
|
||||
|
||||
// Report that we're added _before_ reporting input
|
||||
notifyDeviceAdded();
|
||||
|
||||
while (!isInterrupted() && !stopped) {
|
||||
byte[] buffer = new byte[64];
|
||||
|
||||
int res;
|
||||
|
||||
//
|
||||
// There's no way that I can tell to determine if a device has failed
|
||||
// or if the timeout has simply expired. We'll check how long the transfer
|
||||
// took to fail and assume the device failed if it happened before the timeout
|
||||
// expired.
|
||||
//
|
||||
|
||||
do {
|
||||
// Read the next input state packet
|
||||
long lastMillis = MediaCodecHelper.getMonotonicMillis();
|
||||
res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 3000);
|
||||
|
||||
// If we get a zero length response, treat it as an error
|
||||
if (res == 0) {
|
||||
res = -1;
|
||||
}
|
||||
|
||||
if (res == -1 && MediaCodecHelper.getMonotonicMillis() - lastMillis < 1000) {
|
||||
LimeLog.warning("Detected device I/O error");
|
||||
AbstractXboxController.this.stop();
|
||||
break;
|
||||
}
|
||||
} while (res == -1 && !isInterrupted() && !stopped);
|
||||
|
||||
if (res == -1 || stopped) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (handleRead(ByteBuffer.wrap(buffer, 0, res).order(ByteOrder.LITTLE_ENDIAN))) {
|
||||
// Report input if handleRead() returns true
|
||||
reportInput();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public boolean start() {
|
||||
// Force claim all interfaces
|
||||
for (int i = 0; i < device.getInterfaceCount(); i++) {
|
||||
UsbInterface iface = device.getInterface(i);
|
||||
|
||||
if (!connection.claimInterface(iface, true)) {
|
||||
LimeLog.warning("Failed to claim interfaces");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the endpoints
|
||||
UsbInterface iface = device.getInterface(0);
|
||||
for (int i = 0; i < iface.getEndpointCount(); i++) {
|
||||
UsbEndpoint endpt = iface.getEndpoint(i);
|
||||
if (endpt.getDirection() == UsbConstants.USB_DIR_IN) {
|
||||
if (inEndpt != null) {
|
||||
LimeLog.warning("Found duplicate IN endpoint");
|
||||
return false;
|
||||
}
|
||||
inEndpt = endpt;
|
||||
}
|
||||
else if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) {
|
||||
if (outEndpt != null) {
|
||||
LimeLog.warning("Found duplicate OUT endpoint");
|
||||
return false;
|
||||
}
|
||||
outEndpt = endpt;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the required endpoints were present
|
||||
if (inEndpt == null || outEndpt == null) {
|
||||
LimeLog.warning("Missing required endpoint");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Run the init function
|
||||
if (!doInit()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start listening for controller input
|
||||
inputThread = createInputThread();
|
||||
inputThread.start();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopped = true;
|
||||
|
||||
// Cancel any rumble effects
|
||||
rumble((short)0, (short)0);
|
||||
|
||||
// Stop the input thread
|
||||
if (inputThread != null) {
|
||||
inputThread.interrupt();
|
||||
inputThread = null;
|
||||
}
|
||||
|
||||
// Close the USB connection
|
||||
connection.close();
|
||||
|
||||
// Report the device removed
|
||||
notifyDeviceRemoved();
|
||||
}
|
||||
|
||||
protected abstract boolean handleRead(ByteBuffer buffer);
|
||||
protected abstract boolean doInit();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.limelight.binding.input.driver;
|
||||
|
||||
public interface UsbDriverListener {
|
||||
void reportControllerState(int controllerId, short buttonFlags,
|
||||
float leftStickX, float leftStickY,
|
||||
float rightStickX, float rightStickY,
|
||||
float leftTrigger, float rightTrigger);
|
||||
|
||||
void deviceRemoved(AbstractController controller);
|
||||
void deviceAdded(AbstractController controller);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package com.limelight.binding.input.driver;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.hardware.usb.UsbDevice;
|
||||
import android.hardware.usb.UsbDeviceConnection;
|
||||
import android.hardware.usb.UsbManager;
|
||||
import android.os.Binder;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.view.InputDevice;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.R;
|
||||
import com.limelight.preferences.PreferenceConfiguration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class UsbDriverService extends Service implements UsbDriverListener {
|
||||
|
||||
private static final String ACTION_USB_PERMISSION =
|
||||
"com.limelight.USB_PERMISSION";
|
||||
|
||||
private UsbManager usbManager;
|
||||
private PreferenceConfiguration prefConfig;
|
||||
|
||||
private final UsbEventReceiver receiver = new UsbEventReceiver();
|
||||
private final UsbDriverBinder binder = new UsbDriverBinder();
|
||||
|
||||
private final ArrayList<AbstractController> controllers = new ArrayList<>();
|
||||
|
||||
private UsbDriverListener listener;
|
||||
private int nextDeviceId;
|
||||
|
||||
@Override
|
||||
public void reportControllerState(int controllerId, short buttonFlags, float leftStickX, float leftStickY, float rightStickX, float rightStickY, float leftTrigger, float rightTrigger) {
|
||||
// Call through to the client's listener
|
||||
if (listener != null) {
|
||||
listener.reportControllerState(controllerId, buttonFlags, leftStickX, leftStickY, rightStickX, rightStickY, leftTrigger, rightTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deviceRemoved(AbstractController controller) {
|
||||
// Remove the the controller from our list (if not removed already)
|
||||
controllers.remove(controller);
|
||||
|
||||
// Call through to the client's listener
|
||||
if (listener != null) {
|
||||
listener.deviceRemoved(controller);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deviceAdded(AbstractController controller) {
|
||||
// Call through to the client's listener
|
||||
if (listener != null) {
|
||||
listener.deviceAdded(controller);
|
||||
}
|
||||
}
|
||||
|
||||
public class UsbEventReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String action = intent.getAction();
|
||||
|
||||
// Initial attachment broadcast
|
||||
if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
|
||||
final UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
|
||||
|
||||
// shouldClaimDevice() looks at the kernel's enumerated input
|
||||
// devices to make its decision about whether to prompt to take
|
||||
// control of the device. The kernel bringing up the input stack
|
||||
// may race with this callback and cause us to prompt when the
|
||||
// kernel is capable of running the device. Let's post a delayed
|
||||
// message to process this state change to allow the kernel
|
||||
// some time to bring up the stack.
|
||||
new Handler().postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Continue the state machine
|
||||
handleUsbDeviceState(device);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
// Subsequent permission dialog completion intent
|
||||
else if (action.equals(ACTION_USB_PERMISSION)) {
|
||||
UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_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)) {
|
||||
handleUsbDeviceState(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class UsbDriverBinder extends Binder {
|
||||
public void setListener(UsbDriverListener listener) {
|
||||
UsbDriverService.this.listener = listener;
|
||||
|
||||
// Report all controllerMap that already exist
|
||||
if (listener != null) {
|
||||
for (AbstractController controller : controllers) {
|
||||
listener.deviceAdded(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleUsbDeviceState(UsbDevice device) {
|
||||
// Are we able to operate it?
|
||||
if (shouldClaimDevice(device, prefConfig.bindAllUsb)) {
|
||||
// Do we have permission yet?
|
||||
if (!usbManager.hasPermission(device)) {
|
||||
// Let's ask for permission
|
||||
try {
|
||||
// This function is not documented as throwing any exceptions (denying access
|
||||
// is indicated by calling the PendingIntent with a false result). However,
|
||||
// Samsung Knox has some policies which block this request, but rather than
|
||||
// just returning a false result or returning 0 enumerated devices,
|
||||
// they throw an undocumented SecurityException from this call, crashing
|
||||
// the whole app. :(
|
||||
usbManager.requestPermission(device, PendingIntent.getBroadcast(UsbDriverService.this, 0, new Intent(ACTION_USB_PERMISSION), 0));
|
||||
} catch (SecurityException e) {
|
||||
Toast.makeText(this, this.getText(R.string.error_usb_prohibited), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the device
|
||||
UsbDeviceConnection connection = usbManager.openDevice(device);
|
||||
if (connection == null) {
|
||||
LimeLog.warning("Unable to open USB device: "+device.getDeviceName());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
AbstractController controller;
|
||||
|
||||
if (XboxOneController.canClaimDevice(device)) {
|
||||
controller = new XboxOneController(device, connection, nextDeviceId++, this);
|
||||
}
|
||||
else if (Xbox360Controller.canClaimDevice(device)) {
|
||||
controller = new Xbox360Controller(device, connection, nextDeviceId++, this);
|
||||
}
|
||||
else {
|
||||
// Unreachable
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the controller
|
||||
if (!controller.start()) {
|
||||
connection.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add this controller to the list
|
||||
controllers.add(controller);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isRecognizedInputDevice(UsbDevice device) {
|
||||
// On KitKat and later, we can determine if this VID and PID combo
|
||||
// matches an existing input device and defer to the built-in controller
|
||||
// support in that case. Prior to KitKat, we'll always return true to be safe.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
for (int id : InputDevice.getDeviceIds()) {
|
||||
InputDevice inputDev = InputDevice.getDevice(id);
|
||||
if (inputDev == null) {
|
||||
// Device was removed while looping
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inputDev.getVendorId() == device.getVendorId() &&
|
||||
inputDev.getProductId() == device.getProductId()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean kernelSupportsXboxOne() {
|
||||
String kernelVersion = System.getProperty("os.version");
|
||||
LimeLog.info("Kernel Version: "+kernelVersion);
|
||||
|
||||
if (kernelVersion == null) {
|
||||
// We'll assume this is some newer version of Android
|
||||
// that doesn't let you read the kernel version this way.
|
||||
return true;
|
||||
}
|
||||
else if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.")) {
|
||||
// These are old kernels that definitely don't support Xbox One controllers properly
|
||||
return false;
|
||||
}
|
||||
else if (kernelVersion.startsWith("4.4.") || kernelVersion.startsWith("4.9.")) {
|
||||
// These aren't guaranteed to have backported kernel patches for proper Xbox One
|
||||
// support (though some devices will).
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
// The next AOSP common kernel is 4.14 which has working Xbox One controller support
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean shouldClaimDevice(UsbDevice device, boolean claimAllAvailable) {
|
||||
return ((!kernelSupportsXboxOne() || !isRecognizedInputDevice(device) || claimAllAvailable) && XboxOneController.canClaimDevice(device)) ||
|
||||
((!isRecognizedInputDevice(device) || claimAllAvailable) && Xbox360Controller.canClaimDevice(device));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
this.usbManager = (UsbManager) getSystemService(Context.USB_SERVICE);
|
||||
this.prefConfig = PreferenceConfiguration.readPreferences(this);
|
||||
|
||||
// Register for USB attach broadcasts and permission completions
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
|
||||
filter.addAction(ACTION_USB_PERMISSION);
|
||||
registerReceiver(receiver, filter);
|
||||
|
||||
// Enumerate existing devices
|
||||
for (UsbDevice dev : usbManager.getDeviceList().values()) {
|
||||
if (shouldClaimDevice(dev, prefConfig.bindAllUsb)) {
|
||||
// Start the process of claiming this device
|
||||
handleUsbDeviceState(dev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
// Stop the attachment receiver
|
||||
unregisterReceiver(receiver);
|
||||
|
||||
// Remove listeners
|
||||
listener = null;
|
||||
|
||||
// Stop all controllers
|
||||
while (controllers.size() > 0) {
|
||||
// Stop and remove the controller
|
||||
controllers.remove(0).stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.limelight.binding.input.driver;
|
||||
|
||||
import android.hardware.usb.UsbConstants;
|
||||
import android.hardware.usb.UsbDevice;
|
||||
import android.hardware.usb.UsbDeviceConnection;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.nvstream.input.ControllerPacket;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
public class Xbox360Controller extends AbstractXboxController {
|
||||
private static final int XB360_IFACE_SUBCLASS = 93;
|
||||
private static final int XB360_IFACE_PROTOCOL = 1; // Wired only
|
||||
|
||||
private static final int[] SUPPORTED_VENDORS = {
|
||||
0x0079, // GPD Win 2
|
||||
0x044f, // Thrustmaster
|
||||
0x045e, // Microsoft
|
||||
0x046d, // Logitech
|
||||
0x056e, // Elecom
|
||||
0x06a3, // Saitek
|
||||
0x0738, // Mad Catz
|
||||
0x07ff, // Mad Catz
|
||||
0x0e6f, // Unknown
|
||||
0x0f0d, // Hori
|
||||
0x1038, // SteelSeries
|
||||
0x11c9, // Nacon
|
||||
0x12ab, // Unknown
|
||||
0x1430, // RedOctane
|
||||
0x146b, // BigBen
|
||||
0x1532, // Razer Sabertooth
|
||||
0x15e4, // Numark
|
||||
0x162e, // Joytech
|
||||
0x1689, // Razer Onza
|
||||
0x1bad, // Harmonix
|
||||
0x24c6, // PowerA
|
||||
};
|
||||
|
||||
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() == XB360_IFACE_SUBCLASS &&
|
||||
device.getInterface(0).getInterfaceProtocol() == XB360_IFACE_PROTOCOL) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Xbox360Controller(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
|
||||
super(device, connection, deviceId, listener);
|
||||
}
|
||||
|
||||
private int unsignByte(byte b) {
|
||||
if (b < 0) {
|
||||
return b + 256;
|
||||
}
|
||||
else {
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean handleRead(ByteBuffer buffer) {
|
||||
if (buffer.remaining() < 14) {
|
||||
LimeLog.severe("Read too small: "+buffer.remaining());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip first short
|
||||
buffer.position(buffer.position() + 2);
|
||||
|
||||
// DPAD
|
||||
byte b = buffer.get();
|
||||
setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04);
|
||||
setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08);
|
||||
setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01);
|
||||
setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02);
|
||||
|
||||
// Start/Select
|
||||
setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x10);
|
||||
setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x20);
|
||||
|
||||
// LS/RS
|
||||
setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40);
|
||||
setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80);
|
||||
|
||||
// ABXY buttons
|
||||
b = buffer.get();
|
||||
setButtonFlag(ControllerPacket.A_FLAG, b & 0x10);
|
||||
setButtonFlag(ControllerPacket.B_FLAG, b & 0x20);
|
||||
setButtonFlag(ControllerPacket.X_FLAG, b & 0x40);
|
||||
setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80);
|
||||
|
||||
// LB/RB
|
||||
setButtonFlag(ControllerPacket.LB_FLAG, b & 0x01);
|
||||
setButtonFlag(ControllerPacket.RB_FLAG, b & 0x02);
|
||||
|
||||
// Xbox button
|
||||
setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, b & 0x04);
|
||||
|
||||
// Triggers
|
||||
leftTrigger = unsignByte(buffer.get()) / 255.0f;
|
||||
rightTrigger = unsignByte(buffer.get()) / 255.0f;
|
||||
|
||||
// Left stick
|
||||
leftStickX = buffer.getShort() / 32767.0f;
|
||||
leftStickY = ~buffer.getShort() / 32767.0f;
|
||||
|
||||
// Right stick
|
||||
rightStickX = buffer.getShort() / 32767.0f;
|
||||
rightStickY = ~buffer.getShort() / 32767.0f;
|
||||
|
||||
// Return true to send input
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean sendLedCommand(byte command) {
|
||||
byte[] commandBuffer = {0x01, 0x03, command};
|
||||
|
||||
int res = connection.bulkTransfer(outEndpt, commandBuffer, commandBuffer.length, 3000);
|
||||
if (res != commandBuffer.length) {
|
||||
LimeLog.warning("LED set transfer failed: "+res);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean doInit() {
|
||||
// Turn the LED on corresponding to our device ID
|
||||
sendLedCommand((byte)(2 + (getControllerId() % 4)));
|
||||
|
||||
// No need to fail init if the LED command fails
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rumble(short lowFreqMotor, short highFreqMotor) {
|
||||
byte[] data = {
|
||||
0x00, 0x08, 0x00,
|
||||
(byte)(lowFreqMotor >> 8), (byte)(highFreqMotor >> 8),
|
||||
0x00, 0x00, 0x00
|
||||
};
|
||||
int res = connection.bulkTransfer(outEndpt, data, data.length, 100);
|
||||
if (res != data.length) {
|
||||
LimeLog.warning("Rumble transfer failed: "+res);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.limelight.binding.input.driver;
|
||||
|
||||
import android.hardware.usb.UsbConstants;
|
||||
import android.hardware.usb.UsbDevice;
|
||||
import android.hardware.usb.UsbDeviceConnection;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.nvstream.input.ControllerPacket;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class XboxOneController extends AbstractXboxController {
|
||||
|
||||
private static final int XB1_IFACE_SUBCLASS = 71;
|
||||
private static final int XB1_IFACE_PROTOCOL = 208;
|
||||
|
||||
private static final int[] SUPPORTED_VENDORS = {
|
||||
0x045e, // Microsoft
|
||||
0x0738, // Mad Catz
|
||||
0x0e6f, // Unknown
|
||||
0x0f0d, // Hori
|
||||
0x1532, // Razer Wildcat
|
||||
0x24c6, // PowerA
|
||||
};
|
||||
|
||||
private static final byte[] FW2015_INIT = {0x05, 0x20, 0x00, 0x01, 0x00};
|
||||
private static final byte[] ONE_S_INIT = {0x05, 0x20, 0x00, 0x0f, 0x06};
|
||||
private static final byte[] HORI_INIT = {0x01, 0x20, 0x00, 0x09, 0x00, 0x04, 0x20, 0x3a,
|
||||
0x00, 0x00, 0x00, (byte)0x80, 0x00};
|
||||
private static final byte[] PDP_INIT1 = {0x0a, 0x20, 0x00, 0x03, 0x00, 0x01, 0x14};
|
||||
private static final byte[] PDP_INIT2 = {0x06, 0x20, 0x00, 0x02, 0x01, 0x00};
|
||||
private static final byte[] RUMBLE_INIT1 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00,
|
||||
0x1D, 0x1D, (byte)0xFF, 0x00, 0x00};
|
||||
private static final byte[] RUMBLE_INIT2 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
|
||||
private static InitPacket[] INIT_PKTS = {
|
||||
new InitPacket(0x0e6f, 0x0165, HORI_INIT),
|
||||
new InitPacket(0x0f0d, 0x0067, HORI_INIT),
|
||||
new InitPacket(0x0000, 0x0000, FW2015_INIT),
|
||||
new InitPacket(0x045e, 0x02ea, ONE_S_INIT),
|
||||
new InitPacket(0x045e, 0x0b00, ONE_S_INIT),
|
||||
new InitPacket(0x0e6f, 0x0000, PDP_INIT1),
|
||||
new InitPacket(0x0e6f, 0x0000, PDP_INIT2),
|
||||
new InitPacket(0x24c6, 0x541a, RUMBLE_INIT1),
|
||||
new InitPacket(0x24c6, 0x542a, RUMBLE_INIT1),
|
||||
new InitPacket(0x24c6, 0x543a, RUMBLE_INIT1),
|
||||
new InitPacket(0x24c6, 0x541a, RUMBLE_INIT2),
|
||||
new InitPacket(0x24c6, 0x542a, RUMBLE_INIT2),
|
||||
new InitPacket(0x24c6, 0x543a, RUMBLE_INIT2),
|
||||
};
|
||||
|
||||
private byte seqNum = 0;
|
||||
|
||||
public XboxOneController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
|
||||
super(device, connection, deviceId, listener);
|
||||
}
|
||||
|
||||
private void processButtons(ByteBuffer buffer) {
|
||||
byte b = buffer.get();
|
||||
|
||||
setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x04);
|
||||
setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x08);
|
||||
|
||||
setButtonFlag(ControllerPacket.A_FLAG, b & 0x10);
|
||||
setButtonFlag(ControllerPacket.B_FLAG, b & 0x20);
|
||||
setButtonFlag(ControllerPacket.X_FLAG, b & 0x40);
|
||||
setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80);
|
||||
|
||||
b = buffer.get();
|
||||
setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04);
|
||||
setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08);
|
||||
setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01);
|
||||
setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02);
|
||||
|
||||
setButtonFlag(ControllerPacket.LB_FLAG, b & 0x10);
|
||||
setButtonFlag(ControllerPacket.RB_FLAG, b & 0x20);
|
||||
|
||||
setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40);
|
||||
setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80);
|
||||
|
||||
leftTrigger = buffer.getShort() / 1023.0f;
|
||||
rightTrigger = buffer.getShort() / 1023.0f;
|
||||
|
||||
leftStickX = buffer.getShort() / 32767.0f;
|
||||
leftStickY = ~buffer.getShort() / 32767.0f;
|
||||
|
||||
rightStickX = buffer.getShort() / 32767.0f;
|
||||
rightStickY = ~buffer.getShort() / 32767.0f;
|
||||
}
|
||||
|
||||
private void ackModeReport(byte seqNum) {
|
||||
byte[] payload = {0x01, 0x20, seqNum, 0x09, 0x00, 0x07, 0x20, 0x02,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
connection.bulkTransfer(outEndpt, payload, payload.length, 3000);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean handleRead(ByteBuffer buffer) {
|
||||
switch (buffer.get())
|
||||
{
|
||||
case 0x20:
|
||||
if (buffer.remaining() < 17) {
|
||||
LimeLog.severe("XBone button/axis read too small: "+buffer.remaining());
|
||||
return false;
|
||||
}
|
||||
|
||||
buffer.position(buffer.position()+3);
|
||||
processButtons(buffer);
|
||||
return true;
|
||||
|
||||
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
|
||||
// it retransmits them forever.
|
||||
if (buffer.get() == 0x30) {
|
||||
ackModeReport(buffer.get());
|
||||
buffer.position(buffer.position() + 1);
|
||||
}
|
||||
else {
|
||||
buffer.position(buffer.position() + 2);
|
||||
}
|
||||
setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, buffer.get() & 0x01);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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() == XB1_IFACE_SUBCLASS &&
|
||||
device.getInterface(0).getInterfaceProtocol() == XB1_IFACE_PROTOCOL) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean doInit() {
|
||||
// Send all applicable init packets
|
||||
for (InitPacket pkt : INIT_PKTS) {
|
||||
if (pkt.vendorId != 0 && device.getVendorId() != pkt.vendorId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pkt.productId != 0 && device.getProductId() != pkt.productId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] data = Arrays.copyOf(pkt.data, pkt.data.length);
|
||||
|
||||
// Populate sequence number
|
||||
data[2] = seqNum++;
|
||||
|
||||
// Send the initialization packet
|
||||
int res = connection.bulkTransfer(outEndpt, data, data.length, 3000);
|
||||
if (res != data.length) {
|
||||
LimeLog.warning("Initialization transfer failed: "+res);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rumble(short lowFreqMotor, short highFreqMotor) {
|
||||
byte[] data = {
|
||||
0x09, 0x00, seqNum++, 0x09, 0x00,
|
||||
0x0F, 0x00, 0x00,
|
||||
(byte)(lowFreqMotor >> 9), (byte)(highFreqMotor >> 9),
|
||||
(byte)0xFF, 0x00, (byte)0xFF
|
||||
};
|
||||
int res = connection.bulkTransfer(outEndpt, data, data.length, 100);
|
||||
if (res != data.length) {
|
||||
LimeLog.warning("Rumble transfer failed: "+res);
|
||||
}
|
||||
}
|
||||
|
||||
private static class InitPacket {
|
||||
final int vendorId;
|
||||
final int productId;
|
||||
final byte[] data;
|
||||
|
||||
InitPacket(int vendorId, int productId, byte[] data) {
|
||||
this.vendorId = vendorId;
|
||||
this.productId = productId;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.limelight.binding.input.evdev;
|
||||
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import com.limelight.LimelightBuildProps;
|
||||
import com.limelight.binding.input.capture.InputCaptureProvider;
|
||||
|
||||
public class EvdevCaptureProviderShim {
|
||||
public static boolean isCaptureProviderSupported() {
|
||||
return LimelightBuildProps.ROOT_BUILD;
|
||||
}
|
||||
|
||||
// We need to construct our capture provider using reflection because it isn't included in non-root builds
|
||||
public static InputCaptureProvider createEvdevCaptureProvider(Activity activity, EvdevListener listener) {
|
||||
try {
|
||||
Class providerClass = Class.forName("com.limelight.binding.input.evdev.EvdevCaptureProvider");
|
||||
return (InputCaptureProvider) providerClass.getConstructors()[0].newInstance(activity, listener);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.limelight.binding.input.evdev;
|
||||
|
||||
public interface EvdevListener {
|
||||
int BUTTON_LEFT = 1;
|
||||
int BUTTON_MIDDLE = 2;
|
||||
int BUTTON_RIGHT = 3;
|
||||
int BUTTON_X1 = 4;
|
||||
int BUTTON_X2 = 5;
|
||||
|
||||
void mouseMove(int deltaX, int deltaY);
|
||||
void mouseButtonEvent(int buttonId, boolean down);
|
||||
void mouseScroll(byte amount);
|
||||
void keyboardEvent(boolean buttonDown, short keyCode);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.limelight.binding.input.touch;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.View;
|
||||
|
||||
import com.limelight.nvstream.NvConnection;
|
||||
import com.limelight.nvstream.input.MouseButtonPacket;
|
||||
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
public class AbsoluteTouchContext implements TouchContext {
|
||||
private int lastTouchDownX = 0;
|
||||
private int lastTouchDownY = 0;
|
||||
private long lastTouchDownTime = 0;
|
||||
private int lastTouchUpX = 0;
|
||||
private int lastTouchUpY = 0;
|
||||
private long lastTouchUpTime = 0;
|
||||
private int lastTouchLocationX = 0;
|
||||
private int lastTouchLocationY = 0;
|
||||
private boolean cancelled;
|
||||
private boolean confirmedLongPress;
|
||||
private boolean confirmedTap;
|
||||
private Timer longPressTimer;
|
||||
private Timer tapDownTimer;
|
||||
private float accumulatedScrollDelta;
|
||||
|
||||
private final NvConnection conn;
|
||||
private final int actionIndex;
|
||||
private final View targetView;
|
||||
|
||||
private static final int SCROLL_SPEED_DIVISOR = 20;
|
||||
|
||||
private static final int LONG_PRESS_TIME_THRESHOLD = 650;
|
||||
private static final int LONG_PRESS_DISTANCE_THRESHOLD = 30;
|
||||
|
||||
private static final int DOUBLE_TAP_TIME_THRESHOLD = 250;
|
||||
private static final int DOUBLE_TAP_DISTANCE_THRESHOLD = 60;
|
||||
|
||||
private static final int TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD = 100;
|
||||
private static final int TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD = 20;
|
||||
|
||||
public AbsoluteTouchContext(NvConnection conn, int actionIndex, View view)
|
||||
{
|
||||
this.conn = conn;
|
||||
this.actionIndex = actionIndex;
|
||||
this.targetView = view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getActionIndex()
|
||||
{
|
||||
return actionIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean touchDownEvent(int eventX, int eventY, boolean isNewFinger)
|
||||
{
|
||||
if (!isNewFinger) {
|
||||
// We don't handle finger transitions for absolute mode
|
||||
return true;
|
||||
}
|
||||
|
||||
lastTouchLocationX = lastTouchDownX = eventX;
|
||||
lastTouchLocationY = lastTouchDownY = eventY;
|
||||
lastTouchDownTime = SystemClock.uptimeMillis();
|
||||
cancelled = confirmedTap = confirmedLongPress = false;
|
||||
accumulatedScrollDelta = 0;
|
||||
|
||||
if (actionIndex == 0) {
|
||||
// Start the timers
|
||||
startTapDownTimer();
|
||||
startLongPressTimer();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean distanceExceeds(int deltaX, int deltaY, double limit) {
|
||||
return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)) > limit;
|
||||
}
|
||||
|
||||
private void updatePosition(int eventX, int eventY) {
|
||||
// We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT.
|
||||
// Normalize these to the view size. We can't just drop them because we won't always get an event
|
||||
// right at the boundary of the view, so dropping them would result in our cursor never really
|
||||
// reaching the sides of the screen.
|
||||
eventX = Math.min(Math.max(eventX, 0), targetView.getWidth());
|
||||
eventY = Math.min(Math.max(eventY, 0), targetView.getHeight());
|
||||
|
||||
conn.sendMousePosition((short)eventX, (short)eventY, (short)targetView.getWidth(), (short)targetView.getHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void touchUpEvent(int eventX, int eventY)
|
||||
{
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionIndex == 0) {
|
||||
// Cancel the timers
|
||||
cancelLongPressTimer();
|
||||
cancelTapDownTimer();
|
||||
|
||||
// Raise the mouse buttons that we currently have down
|
||||
if (confirmedLongPress) {
|
||||
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
|
||||
}
|
||||
else if (confirmedTap) {
|
||||
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
|
||||
}
|
||||
else {
|
||||
// If we get here, this means that the tap completed within the touch down
|
||||
// deadzone time. We'll need to send the touch down and up events now at the
|
||||
// original touch down position.
|
||||
tapConfirmed();
|
||||
try {
|
||||
// FIXME: Sleeping on the main thread sucks
|
||||
Thread.sleep(50);
|
||||
} catch (InterruptedException ignored) {}
|
||||
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
lastTouchLocationX = lastTouchUpX = eventX;
|
||||
lastTouchLocationY = lastTouchUpY = eventY;
|
||||
lastTouchUpTime = SystemClock.uptimeMillis();
|
||||
}
|
||||
|
||||
private synchronized void startLongPressTimer() {
|
||||
longPressTimer = new Timer(true);
|
||||
longPressTimer.schedule(new TimerTask() {
|
||||
@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() {
|
||||
if (longPressTimer != null) {
|
||||
longPressTimer.cancel();
|
||||
longPressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void startTapDownTimer() {
|
||||
tapDownTimer = new Timer(true);
|
||||
tapDownTimer.schedule(new TimerTask() {
|
||||
@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() {
|
||||
if (tapDownTimer != null) {
|
||||
tapDownTimer.cancel();
|
||||
tapDownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void tapConfirmed() {
|
||||
if (confirmedTap || confirmedLongPress) {
|
||||
return;
|
||||
}
|
||||
|
||||
confirmedTap = true;
|
||||
cancelTapDownTimer();
|
||||
|
||||
// Left button down at original position
|
||||
if (lastTouchDownTime - lastTouchUpTime > DOUBLE_TAP_TIME_THRESHOLD ||
|
||||
distanceExceeds(lastTouchDownX - lastTouchUpX, lastTouchDownY - lastTouchUpY, DOUBLE_TAP_DISTANCE_THRESHOLD)) {
|
||||
// Don't reposition for finger down events within the deadzone. This makes double-clicking easier.
|
||||
updatePosition(lastTouchDownX, lastTouchDownY);
|
||||
}
|
||||
conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean touchMoveEvent(int eventX, int eventY)
|
||||
{
|
||||
if (cancelled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actionIndex == 0) {
|
||||
if (distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, LONG_PRESS_DISTANCE_THRESHOLD)) {
|
||||
// Moved too far since touch down. Cancel the long press timer.
|
||||
cancelLongPressTimer();
|
||||
}
|
||||
|
||||
// Ignore motion within the deadzone period after touch down
|
||||
if (confirmedTap || distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD)) {
|
||||
tapConfirmed();
|
||||
updatePosition(eventX, eventY);
|
||||
}
|
||||
}
|
||||
else if (actionIndex == 1) {
|
||||
accumulatedScrollDelta += (eventY - lastTouchLocationY) / (float)SCROLL_SPEED_DIVISOR;
|
||||
if ((short)accumulatedScrollDelta != 0) {
|
||||
conn.sendMouseHighResScroll((short)accumulatedScrollDelta);
|
||||
accumulatedScrollDelta -= (short)accumulatedScrollDelta;
|
||||
}
|
||||
}
|
||||
|
||||
lastTouchLocationX = eventX;
|
||||
lastTouchLocationY = eventY;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelTouch() {
|
||||
cancelled = true;
|
||||
|
||||
// Cancel the timers
|
||||
cancelLongPressTimer();
|
||||
cancelTapDownTimer();
|
||||
|
||||
// Raise the mouse buttons
|
||||
if (confirmedLongPress) {
|
||||
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
|
||||
}
|
||||
else if (confirmedTap) {
|
||||
conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCancelled() {
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPointerCount(int pointerCount) {
|
||||
if (actionIndex == 0 && pointerCount > 1) {
|
||||
cancelTouch();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.limelight.binding.input.touch;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.View;
|
||||
|
||||
import com.limelight.nvstream.NvConnection;
|
||||
import com.limelight.nvstream.input.MouseButtonPacket;
|
||||
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
public class RelativeTouchContext implements TouchContext {
|
||||
private int lastTouchX = 0;
|
||||
private int lastTouchY = 0;
|
||||
private int originalTouchX = 0;
|
||||
private int originalTouchY = 0;
|
||||
private long originalTouchTime = 0;
|
||||
private boolean cancelled;
|
||||
private boolean confirmedMove;
|
||||
private boolean confirmedDrag;
|
||||
private boolean confirmedScroll;
|
||||
private Timer dragTimer;
|
||||
private double distanceMoved;
|
||||
private double xFactor, yFactor;
|
||||
private int pointerCount;
|
||||
private int maxPointerCountInGesture;
|
||||
|
||||
private final NvConnection conn;
|
||||
private final int actionIndex;
|
||||
private final int referenceWidth;
|
||||
private final int referenceHeight;
|
||||
private final View targetView;
|
||||
|
||||
private static final int TAP_MOVEMENT_THRESHOLD = 20;
|
||||
private static final int TAP_DISTANCE_THRESHOLD = 25;
|
||||
private static final int TAP_TIME_THRESHOLD = 250;
|
||||
private static final int DRAG_TIME_THRESHOLD = 650;
|
||||
|
||||
private static final int SCROLL_SPEED_DIVISOR = 20;
|
||||
|
||||
public RelativeTouchContext(NvConnection conn, int actionIndex,
|
||||
int referenceWidth, int referenceHeight, View view)
|
||||
{
|
||||
this.conn = conn;
|
||||
this.actionIndex = actionIndex;
|
||||
this.referenceWidth = referenceWidth;
|
||||
this.referenceHeight = referenceHeight;
|
||||
this.targetView = view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getActionIndex()
|
||||
{
|
||||
return actionIndex;
|
||||
}
|
||||
|
||||
private boolean isWithinTapBounds(int touchX, int touchY)
|
||||
{
|
||||
int xDelta = Math.abs(touchX - originalTouchX);
|
||||
int yDelta = Math.abs(touchY - originalTouchY);
|
||||
return xDelta <= TAP_MOVEMENT_THRESHOLD &&
|
||||
yDelta <= TAP_MOVEMENT_THRESHOLD;
|
||||
}
|
||||
|
||||
private boolean isTap()
|
||||
{
|
||||
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 = SystemClock.uptimeMillis() - originalTouchTime;
|
||||
return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD;
|
||||
}
|
||||
|
||||
private byte getMouseButtonIndex()
|
||||
{
|
||||
if (actionIndex == 1) {
|
||||
return MouseButtonPacket.BUTTON_RIGHT;
|
||||
}
|
||||
else {
|
||||
return MouseButtonPacket.BUTTON_LEFT;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean touchDownEvent(int eventX, int eventY, boolean isNewFinger)
|
||||
{
|
||||
// Get the view dimensions to scale inputs on this touch
|
||||
xFactor = referenceWidth / (double)targetView.getWidth();
|
||||
yFactor = referenceHeight / (double)targetView.getHeight();
|
||||
|
||||
originalTouchX = lastTouchX = eventX;
|
||||
originalTouchY = lastTouchY = eventY;
|
||||
|
||||
if (isNewFinger) {
|
||||
maxPointerCountInGesture = pointerCount;
|
||||
originalTouchTime = SystemClock.uptimeMillis();
|
||||
cancelled = confirmedDrag = confirmedMove = confirmedScroll = false;
|
||||
distanceMoved = 0;
|
||||
|
||||
if (actionIndex == 0) {
|
||||
// Start the timer for engaging a drag
|
||||
startDragTimer();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void touchUpEvent(int eventX, int eventY)
|
||||
{
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel the drag timer
|
||||
cancelDragTimer();
|
||||
|
||||
byte buttonIndex = getMouseButtonIndex();
|
||||
|
||||
if (confirmedDrag) {
|
||||
// Raise the button after a drag
|
||||
conn.sendMouseButtonUp(buttonIndex);
|
||||
}
|
||||
else if (isTap())
|
||||
{
|
||||
// Lower the mouse button
|
||||
conn.sendMouseButtonDown(buttonIndex);
|
||||
|
||||
// We need to sleep a bit here because some games
|
||||
// do input detection by polling
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException ignored) {}
|
||||
|
||||
// Raise the mouse button
|
||||
conn.sendMouseButtonUp(buttonIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void startDragTimer() {
|
||||
dragTimer = new Timer(true);
|
||||
dragTimer.schedule(new TimerTask() {
|
||||
@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() {
|
||||
if (dragTimer != null) {
|
||||
dragTimer.cancel();
|
||||
dragTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void checkForConfirmedMove(int eventX, int eventY) {
|
||||
// If we've already confirmed something, get out now
|
||||
if (confirmedMove || confirmedDrag) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If it leaves the tap bounds before the drag time expires, it's a move.
|
||||
if (!isWithinTapBounds(eventX, eventY)) {
|
||||
confirmedMove = true;
|
||||
cancelDragTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've exceeded the maximum distance moved
|
||||
distanceMoved += Math.sqrt(Math.pow(eventX - lastTouchX, 2) + Math.pow(eventY - lastTouchY, 2));
|
||||
if (distanceMoved >= TAP_DISTANCE_THRESHOLD) {
|
||||
confirmedMove = true;
|
||||
cancelDragTimer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
public boolean touchMoveEvent(int eventX, int eventY)
|
||||
{
|
||||
if (cancelled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (eventX != lastTouchX || eventY != lastTouchY)
|
||||
{
|
||||
checkForConfirmedMove(eventX, eventY);
|
||||
checkForConfirmedScroll();
|
||||
|
||||
// We only send moves and drags for the primary touch point
|
||||
if (actionIndex == 0) {
|
||||
int deltaX = eventX - lastTouchX;
|
||||
int deltaY = eventY - lastTouchY;
|
||||
|
||||
// Scale the deltas based on the factors passed to our constructor
|
||||
deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor);
|
||||
deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor);
|
||||
|
||||
// Fix up the signs
|
||||
if (eventX < lastTouchX) {
|
||||
deltaX = -deltaX;
|
||||
}
|
||||
if (eventY < lastTouchY) {
|
||||
deltaY = -deltaY;
|
||||
}
|
||||
|
||||
if (pointerCount == 2) {
|
||||
if (confirmedScroll) {
|
||||
deltaY /= SCROLL_SPEED_DIVISOR;
|
||||
|
||||
conn.sendMouseHighResScroll((short) deltaY);
|
||||
}
|
||||
} else {
|
||||
conn.sendMouseMove((short) deltaX, (short) deltaY);
|
||||
}
|
||||
|
||||
// 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
|
||||
// will work correctly
|
||||
if (deltaX != 0) {
|
||||
lastTouchX = eventX;
|
||||
}
|
||||
if (deltaY != 0) {
|
||||
lastTouchY = eventY;
|
||||
}
|
||||
}
|
||||
else {
|
||||
lastTouchX = eventX;
|
||||
lastTouchY = eventY;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelTouch() {
|
||||
cancelled = true;
|
||||
|
||||
// Cancel the drag timer
|
||||
cancelDragTimer();
|
||||
|
||||
// If it was a confirmed drag, we'll need to raise the button now
|
||||
if (confirmedDrag) {
|
||||
conn.sendMouseButtonUp(getMouseButtonIndex());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCancelled() {
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPointerCount(int pointerCount) {
|
||||
this.pointerCount = pointerCount;
|
||||
|
||||
if (pointerCount > maxPointerCountInGesture) {
|
||||
maxPointerCountInGesture = pointerCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.limelight.binding.input.touch;
|
||||
|
||||
public interface TouchContext {
|
||||
int getActionIndex();
|
||||
void setPointerCount(int pointerCount);
|
||||
boolean touchDownEvent(int eventX, int eventY, boolean isNewFinger);
|
||||
boolean touchMoveEvent(int eventX, int eventY);
|
||||
void touchUpEvent(int eventX, int eventY);
|
||||
void cancelTouch();
|
||||
boolean isCancelled();
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* This is a analog stick on screen element. It is used to get 2-Axis user input.
|
||||
*/
|
||||
public class AnalogStick extends VirtualControllerElement {
|
||||
|
||||
/**
|
||||
* outer radius size in percent of the ui element
|
||||
*/
|
||||
public static final int SIZE_RADIUS_COMPLETE = 90;
|
||||
/**
|
||||
* analog stick size in percent of the ui element
|
||||
*/
|
||||
public static final int SIZE_RADIUS_ANALOG_STICK = 90;
|
||||
/**
|
||||
* dead zone size in percent of the ui element
|
||||
*/
|
||||
public static final int SIZE_RADIUS_DEADZONE = 90;
|
||||
/**
|
||||
* time frame for a double click
|
||||
*/
|
||||
public final static long timeoutDoubleClick = 350;
|
||||
|
||||
/**
|
||||
* touch down time until the deadzone is lifted to allow precise movements with the analog sticks
|
||||
*/
|
||||
public final static long timeoutDeadzone = 150;
|
||||
|
||||
/**
|
||||
* Listener interface to update registered observers.
|
||||
*/
|
||||
public interface AnalogStickListener {
|
||||
|
||||
/**
|
||||
* onMovement event will be fired on real analog stick movement (outside of the deadzone).
|
||||
*
|
||||
* @param x horizontal position, value from -1.0 ... 0 .. 1.0
|
||||
* @param y vertical position, value from -1.0 ... 0 .. 1.0
|
||||
*/
|
||||
void onMovement(float x, float y);
|
||||
|
||||
/**
|
||||
* onClick event will be fired on click on the analog stick
|
||||
*/
|
||||
void onClick();
|
||||
|
||||
/**
|
||||
* onDoubleClick event will be fired on a double click in a short time frame on the analog
|
||||
* stick.
|
||||
*/
|
||||
void onDoubleClick();
|
||||
|
||||
/**
|
||||
* onRevoke event will be fired on unpress of the analog stick.
|
||||
*/
|
||||
void onRevoke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Movement states of the analog sick.
|
||||
*/
|
||||
private enum STICK_STATE {
|
||||
NO_MOVEMENT,
|
||||
MOVED_IN_DEAD_ZONE,
|
||||
MOVED_ACTIVE
|
||||
}
|
||||
|
||||
/**
|
||||
* Click type states.
|
||||
*/
|
||||
private enum CLICK_STATE {
|
||||
SINGLE,
|
||||
DOUBLE
|
||||
}
|
||||
|
||||
/**
|
||||
* configuration if the analog stick should be displayed as circle or square
|
||||
*/
|
||||
private boolean circle_stick = true; // TODO: implement square sick for simulations
|
||||
|
||||
/**
|
||||
* outer radius, this size will be automatically updated on resize
|
||||
*/
|
||||
private float radius_complete = 0;
|
||||
/**
|
||||
* analog stick radius, this size will be automatically updated on resize
|
||||
*/
|
||||
private float radius_analog_stick = 0;
|
||||
/**
|
||||
* dead zone radius, this size will be automatically updated on resize
|
||||
*/
|
||||
private float radius_dead_zone = 0;
|
||||
|
||||
/**
|
||||
* horizontal position in relation to the center of the element
|
||||
*/
|
||||
private float relative_x = 0;
|
||||
/**
|
||||
* vertical position in relation to the center of the element
|
||||
*/
|
||||
private float relative_y = 0;
|
||||
|
||||
|
||||
private double movement_radius = 0;
|
||||
private double movement_angle = 0;
|
||||
|
||||
private float position_stick_x = 0;
|
||||
private float position_stick_y = 0;
|
||||
|
||||
private final Paint paint = new Paint();
|
||||
|
||||
private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT;
|
||||
private CLICK_STATE click_state = CLICK_STATE.SINGLE;
|
||||
|
||||
private List<AnalogStickListener> listeners = new ArrayList<>();
|
||||
private long timeLastClick = 0;
|
||||
|
||||
private static double getMovementRadius(float x, float y) {
|
||||
return Math.sqrt(x * x + y * y);
|
||||
}
|
||||
|
||||
private static double getAngle(float way_x, float way_y) {
|
||||
// prevent divisions by zero for corner cases
|
||||
if (way_x == 0) {
|
||||
return way_y < 0 ? Math.PI : 0;
|
||||
} else if (way_y == 0) {
|
||||
if (way_x > 0) {
|
||||
return Math.PI * 3 / 2;
|
||||
} else if (way_x < 0) {
|
||||
return Math.PI * 1 / 2;
|
||||
}
|
||||
}
|
||||
// return correct calculated angle for each quadrant
|
||||
if (way_x > 0) {
|
||||
if (way_y < 0) {
|
||||
// first quadrant
|
||||
return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x));
|
||||
} else {
|
||||
// second quadrant
|
||||
return Math.PI + Math.atan((double) (way_x / way_y));
|
||||
}
|
||||
} else {
|
||||
if (way_y > 0) {
|
||||
// third quadrant
|
||||
return Math.PI / 2 + Math.atan((double) (way_y / -way_x));
|
||||
} else {
|
||||
// fourth quadrant
|
||||
return 0 + Math.atan((double) (-way_x / -way_y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AnalogStick(VirtualController controller, Context context, int elementId) {
|
||||
super(controller, context, elementId);
|
||||
// reset stick position
|
||||
position_stick_x = getWidth() / 2;
|
||||
position_stick_y = getHeight() / 2;
|
||||
}
|
||||
|
||||
public void addAnalogStickListener(AnalogStickListener listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
private void notifyOnMovement(float x, float y) {
|
||||
_DBG("movement x: " + x + " movement y: " + y);
|
||||
// notify listeners
|
||||
for (AnalogStickListener listener : listeners) {
|
||||
listener.onMovement(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyOnClick() {
|
||||
_DBG("click");
|
||||
// notify listeners
|
||||
for (AnalogStickListener listener : listeners) {
|
||||
listener.onClick();
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyOnDoubleClick() {
|
||||
_DBG("double click");
|
||||
// notify listeners
|
||||
for (AnalogStickListener listener : listeners) {
|
||||
listener.onDoubleClick();
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyOnRevoke() {
|
||||
_DBG("revoke");
|
||||
// notify listeners
|
||||
for (AnalogStickListener listener : listeners) {
|
||||
listener.onRevoke();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
// calculate new radius sizes depending
|
||||
radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth();
|
||||
radius_dead_zone = getPercent(getCorrectWidth() / 2, 30);
|
||||
radius_analog_stick = getPercent(getCorrectWidth() / 2, 20);
|
||||
|
||||
super.onSizeChanged(w, h, oldw, oldh);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onElementDraw(Canvas canvas) {
|
||||
// set transparent background
|
||||
canvas.drawColor(Color.TRANSPARENT);
|
||||
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
paint.setStrokeWidth(getDefaultStrokeWidth());
|
||||
|
||||
// draw outer circle
|
||||
if (!isPressed() || click_state == CLICK_STATE.SINGLE) {
|
||||
paint.setColor(getDefaultColor());
|
||||
} else {
|
||||
paint.setColor(pressedColor);
|
||||
}
|
||||
canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint);
|
||||
|
||||
paint.setColor(getDefaultColor());
|
||||
// draw dead zone
|
||||
canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint);
|
||||
|
||||
// draw stick depending on state
|
||||
switch (stick_state) {
|
||||
case NO_MOVEMENT: {
|
||||
paint.setColor(getDefaultColor());
|
||||
canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint);
|
||||
break;
|
||||
}
|
||||
case MOVED_IN_DEAD_ZONE:
|
||||
case MOVED_ACTIVE: {
|
||||
paint.setColor(pressedColor);
|
||||
canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePosition() {
|
||||
// get 100% way
|
||||
float complete = radius_complete - radius_analog_stick;
|
||||
|
||||
// calculate relative way
|
||||
float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius));
|
||||
float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius));
|
||||
|
||||
// update positions
|
||||
position_stick_x = getWidth() / 2 - correlated_x;
|
||||
position_stick_y = getHeight() / 2 - correlated_y;
|
||||
|
||||
// Stay active even if we're back in the deadzone because we know the user is actively
|
||||
// giving analog stick input and we don't want to snap back into the deadzone.
|
||||
// We also release the deadzone if the user keeps the stick pressed for a bit to allow
|
||||
// them to make precise movements.
|
||||
stick_state = (stick_state == STICK_STATE.MOVED_ACTIVE ||
|
||||
SystemClock.uptimeMillis() - timeLastClick > timeoutDeadzone ||
|
||||
movement_radius > radius_dead_zone) ?
|
||||
STICK_STATE.MOVED_ACTIVE : STICK_STATE.MOVED_IN_DEAD_ZONE;
|
||||
|
||||
// trigger move event if state active
|
||||
if (stick_state == STICK_STATE.MOVED_ACTIVE) {
|
||||
notifyOnMovement(-correlated_x / complete, correlated_y / complete);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onElementTouchEvent(MotionEvent event) {
|
||||
// save last click state
|
||||
CLICK_STATE lastClickState = click_state;
|
||||
|
||||
// get absolute way for each axis
|
||||
relative_x = -(getWidth() / 2 - event.getX());
|
||||
relative_y = -(getHeight() / 2 - event.getY());
|
||||
|
||||
// get radius and angel of movement from center
|
||||
movement_radius = getMovementRadius(relative_x, relative_y);
|
||||
movement_angle = getAngle(relative_x, relative_y);
|
||||
|
||||
// pass touch event to parent if out of outer circle
|
||||
if (movement_radius > radius_complete && !isPressed())
|
||||
return false;
|
||||
|
||||
// chop radius if out of outer circle or near the edge
|
||||
if (movement_radius > (radius_complete - radius_analog_stick)) {
|
||||
movement_radius = radius_complete - radius_analog_stick;
|
||||
}
|
||||
|
||||
// handle event depending on action
|
||||
switch (event.getActionMasked()) {
|
||||
// down event (touch event)
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
case MotionEvent.ACTION_POINTER_DOWN: {
|
||||
// set to dead zoned, will be corrected in update position if necessary
|
||||
stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE;
|
||||
// check for double click
|
||||
if (lastClickState == CLICK_STATE.SINGLE &&
|
||||
timeLastClick + timeoutDoubleClick > SystemClock.uptimeMillis()) {
|
||||
click_state = CLICK_STATE.DOUBLE;
|
||||
notifyOnDoubleClick();
|
||||
} else {
|
||||
click_state = CLICK_STATE.SINGLE;
|
||||
notifyOnClick();
|
||||
}
|
||||
// reset last click timestamp
|
||||
timeLastClick = SystemClock.uptimeMillis();
|
||||
// set item pressed and update
|
||||
setPressed(true);
|
||||
break;
|
||||
}
|
||||
// up event (revoke touch)
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_POINTER_UP: {
|
||||
setPressed(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPressed()) {
|
||||
// when is pressed calculate new positions (will trigger movement if necessary)
|
||||
updatePosition();
|
||||
} else {
|
||||
stick_state = STICK_STATE.NO_MOVEMENT;
|
||||
notifyOnRevoke();
|
||||
|
||||
// not longer pressed reset analog stick
|
||||
notifyOnMovement(0, 0);
|
||||
}
|
||||
// refresh view
|
||||
invalidate();
|
||||
// accept the touch event
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
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.
|
||||
*/
|
||||
public class DigitalButton extends VirtualControllerElement {
|
||||
|
||||
/**
|
||||
* Listener interface to update registered observers.
|
||||
*/
|
||||
public interface DigitalButtonListener {
|
||||
|
||||
/**
|
||||
* onClick event will be fired on button click.
|
||||
*/
|
||||
void onClick();
|
||||
|
||||
/**
|
||||
* onLongClick event will be fired on button long click.
|
||||
*/
|
||||
void onLongClick();
|
||||
|
||||
/**
|
||||
* onRelease event will be fired on button unpress.
|
||||
*/
|
||||
void onRelease();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
private class TimerLongClickTimerTask extends TimerTask {
|
||||
@Override
|
||||
public void run() {
|
||||
onLongClickCallback();
|
||||
}
|
||||
}
|
||||
|
||||
private List<DigitalButtonListener> listeners = new ArrayList<>();
|
||||
private String text = "";
|
||||
private int icon = -1;
|
||||
private long timerLongClickTimeout = 3000;
|
||||
private Timer timerLongClick = null;
|
||||
private TimerLongClickTimerTask longClickTimerTask = null;
|
||||
|
||||
private final Paint paint = new Paint();
|
||||
private final RectF rect = new RectF();
|
||||
|
||||
private int layer;
|
||||
private DigitalButton movingButton = null;
|
||||
|
||||
boolean inRange(float x, float y) {
|
||||
return (this.getX() < x && this.getX() + this.getWidth() > x) &&
|
||||
(this.getY() < y && this.getY() + this.getHeight() > y);
|
||||
}
|
||||
|
||||
public boolean checkMovement(float x, float y, DigitalButton movingButton) {
|
||||
// check if the movement happened in the same layer
|
||||
if (movingButton.layer != this.layer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// save current pressed state
|
||||
boolean wasPressed = isPressed();
|
||||
|
||||
// check if the movement directly happened on the button
|
||||
if ((this.movingButton == null || movingButton == this.movingButton)
|
||||
&& this.inRange(x, y)) {
|
||||
// set button pressed state depending on moving button pressed state
|
||||
if (this.isPressed() != movingButton.isPressed()) {
|
||||
this.setPressed(movingButton.isPressed());
|
||||
}
|
||||
}
|
||||
// check if the movement is outside of the range and the movement button
|
||||
// is the saved moving button
|
||||
else if (movingButton == this.movingButton) {
|
||||
this.setPressed(false);
|
||||
}
|
||||
|
||||
// check if a change occurred
|
||||
if (wasPressed != isPressed()) {
|
||||
if (isPressed()) {
|
||||
// is pressed set moving button and emit click event
|
||||
this.movingButton = movingButton;
|
||||
onClickCallback();
|
||||
} else {
|
||||
// no longer pressed reset moving button and emit release event
|
||||
this.movingButton = null;
|
||||
onReleaseCallback();
|
||||
}
|
||||
|
||||
invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void checkMovementForAllButtons(float x, float y) {
|
||||
for (VirtualControllerElement element : virtualController.getElements()) {
|
||||
if (element != this && element instanceof DigitalButton) {
|
||||
((DigitalButton) element).checkMovement(x, y, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DigitalButton(VirtualController controller, int elementId, int layer, Context context) {
|
||||
super(controller, context, elementId);
|
||||
this.layer = layer;
|
||||
}
|
||||
|
||||
public void addDigitalButtonListener(DigitalButtonListener listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
public void setText(String text) {
|
||||
this.text = text;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setIcon(int id) {
|
||||
this.icon = id;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onElementDraw(Canvas canvas) {
|
||||
// set transparent background
|
||||
canvas.drawColor(Color.TRANSPARENT);
|
||||
|
||||
paint.setTextSize(getPercent(getWidth(), 25));
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
paint.setStrokeWidth(getDefaultStrokeWidth());
|
||||
|
||||
paint.setColor(isPressed() ? pressedColor : getDefaultColor());
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
|
||||
rect.left = rect.top = paint.getStrokeWidth();
|
||||
rect.right = getWidth() - rect.left;
|
||||
rect.bottom = getHeight() - rect.top;
|
||||
|
||||
canvas.drawOval(rect, paint);
|
||||
|
||||
if (icon != -1) {
|
||||
Drawable d = getResources().getDrawable(icon);
|
||||
d.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
|
||||
d.draw(canvas);
|
||||
} else {
|
||||
paint.setStyle(Paint.Style.FILL_AND_STROKE);
|
||||
paint.setStrokeWidth(getDefaultStrokeWidth()/2);
|
||||
canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint);
|
||||
}
|
||||
}
|
||||
|
||||
private void onClickCallback() {
|
||||
_DBG("clicked");
|
||||
// notify listeners
|
||||
for (DigitalButtonListener listener : listeners) {
|
||||
listener.onClick();
|
||||
}
|
||||
|
||||
timerLongClick = new Timer();
|
||||
longClickTimerTask = new TimerLongClickTimerTask();
|
||||
timerLongClick.schedule(longClickTimerTask, timerLongClickTimeout);
|
||||
}
|
||||
|
||||
private void onLongClickCallback() {
|
||||
_DBG("long click");
|
||||
// notify listeners
|
||||
for (DigitalButtonListener listener : listeners) {
|
||||
listener.onLongClick();
|
||||
}
|
||||
}
|
||||
|
||||
private void onReleaseCallback() {
|
||||
_DBG("released");
|
||||
// notify listeners
|
||||
for (DigitalButtonListener listener : listeners) {
|
||||
listener.onRelease();
|
||||
}
|
||||
|
||||
// We may be called for a release without a prior click
|
||||
if (timerLongClick != null) {
|
||||
timerLongClick.cancel();
|
||||
}
|
||||
if (longClickTimerTask != null) {
|
||||
longClickTimerTask.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onElementTouchEvent(MotionEvent event) {
|
||||
// get masked (not specific to a pointer) action
|
||||
float x = getX() + event.getX();
|
||||
float y = getY() + event.getY();
|
||||
int action = event.getActionMasked();
|
||||
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
case MotionEvent.ACTION_POINTER_DOWN: {
|
||||
movingButton = null;
|
||||
setPressed(true);
|
||||
onClickCallback();
|
||||
|
||||
invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
case MotionEvent.ACTION_MOVE: {
|
||||
checkMovementForAllButtons(x, y);
|
||||
|
||||
return true;
|
||||
}
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_POINTER_UP: {
|
||||
setPressed(false);
|
||||
onReleaseCallback();
|
||||
|
||||
checkMovementForAllButtons(x, y);
|
||||
|
||||
invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class DigitalPad extends VirtualControllerElement {
|
||||
public final static int DIGITAL_PAD_DIRECTION_NO_DIRECTION = 0;
|
||||
int direction = DIGITAL_PAD_DIRECTION_NO_DIRECTION;
|
||||
public final static int DIGITAL_PAD_DIRECTION_LEFT = 1;
|
||||
public final static int DIGITAL_PAD_DIRECTION_UP = 2;
|
||||
public final static int DIGITAL_PAD_DIRECTION_RIGHT = 4;
|
||||
public final static int DIGITAL_PAD_DIRECTION_DOWN = 8;
|
||||
List<DigitalPadListener> listeners = new ArrayList<>();
|
||||
|
||||
private static final int DPAD_MARGIN = 5;
|
||||
|
||||
private final Paint paint = new Paint();
|
||||
|
||||
public DigitalPad(VirtualController controller, Context context) {
|
||||
super(controller, context, EID_DPAD);
|
||||
}
|
||||
|
||||
public void addDigitalPadListener(DigitalPadListener listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onElementDraw(Canvas canvas) {
|
||||
// set transparent background
|
||||
canvas.drawColor(Color.TRANSPARENT);
|
||||
|
||||
paint.setTextSize(getPercent(getCorrectWidth(), 20));
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
paint.setStrokeWidth(getDefaultStrokeWidth());
|
||||
|
||||
if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) {
|
||||
// draw no direction rect
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
paint.setColor(getDefaultColor());
|
||||
canvas.drawRect(
|
||||
getPercent(getWidth(), 36), getPercent(getHeight(), 36),
|
||||
getPercent(getWidth(), 63), getPercent(getHeight(), 63),
|
||||
paint
|
||||
);
|
||||
}
|
||||
|
||||
// draw left rect
|
||||
paint.setColor(
|
||||
(direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 ? pressedColor : getDefaultColor());
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
canvas.drawRect(
|
||||
paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33),
|
||||
getPercent(getWidth(), 33), getPercent(getHeight(), 66),
|
||||
paint
|
||||
);
|
||||
|
||||
|
||||
// draw up rect
|
||||
paint.setColor(
|
||||
(direction & DIGITAL_PAD_DIRECTION_UP) > 0 ? pressedColor : getDefaultColor());
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
canvas.drawRect(
|
||||
getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN,
|
||||
getPercent(getWidth(), 66), getPercent(getHeight(), 33),
|
||||
paint
|
||||
);
|
||||
|
||||
// draw right rect
|
||||
paint.setColor(
|
||||
(direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 ? pressedColor : getDefaultColor());
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
canvas.drawRect(
|
||||
getPercent(getWidth(), 66), getPercent(getHeight(), 33),
|
||||
getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 66),
|
||||
paint
|
||||
);
|
||||
|
||||
// draw down rect
|
||||
paint.setColor(
|
||||
(direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 ? pressedColor : getDefaultColor());
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
canvas.drawRect(
|
||||
getPercent(getWidth(), 33), getPercent(getHeight(), 66),
|
||||
getPercent(getWidth(), 66), getHeight() - (paint.getStrokeWidth()+DPAD_MARGIN),
|
||||
paint
|
||||
);
|
||||
|
||||
// draw left up line
|
||||
paint.setColor((
|
||||
(direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 &&
|
||||
(direction & DIGITAL_PAD_DIRECTION_UP) > 0
|
||||
) ? pressedColor : getDefaultColor()
|
||||
);
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
canvas.drawLine(
|
||||
paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33),
|
||||
getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN,
|
||||
paint
|
||||
);
|
||||
|
||||
// draw up right line
|
||||
paint.setColor((
|
||||
(direction & DIGITAL_PAD_DIRECTION_UP) > 0 &&
|
||||
(direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0
|
||||
) ? pressedColor : getDefaultColor()
|
||||
);
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
canvas.drawLine(
|
||||
getPercent(getWidth(), 66), paint.getStrokeWidth()+DPAD_MARGIN,
|
||||
getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 33),
|
||||
paint
|
||||
);
|
||||
|
||||
// draw right down line
|
||||
paint.setColor((
|
||||
(direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 &&
|
||||
(direction & DIGITAL_PAD_DIRECTION_DOWN) > 0
|
||||
) ? pressedColor : getDefaultColor()
|
||||
);
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
canvas.drawLine(
|
||||
getWidth()-paint.getStrokeWidth(), getPercent(getHeight(), 66),
|
||||
getPercent(getWidth(), 66), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN),
|
||||
paint
|
||||
);
|
||||
|
||||
// draw down left line
|
||||
paint.setColor((
|
||||
(direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 &&
|
||||
(direction & DIGITAL_PAD_DIRECTION_LEFT) > 0
|
||||
) ? pressedColor : getDefaultColor()
|
||||
);
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
canvas.drawLine(
|
||||
getPercent(getWidth(), 33), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN),
|
||||
paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 66),
|
||||
paint
|
||||
);
|
||||
}
|
||||
|
||||
private void newDirectionCallback(int direction) {
|
||||
_DBG("direction: " + direction);
|
||||
|
||||
// notify listeners
|
||||
for (DigitalPadListener listener : listeners) {
|
||||
listener.onDirectionChange(direction);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onElementTouchEvent(MotionEvent event) {
|
||||
// get masked (not specific to a pointer) action
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
case MotionEvent.ACTION_POINTER_DOWN:
|
||||
case MotionEvent.ACTION_MOVE: {
|
||||
direction = 0;
|
||||
|
||||
if (event.getX() < getPercent(getWidth(), 33)) {
|
||||
direction |= DIGITAL_PAD_DIRECTION_LEFT;
|
||||
}
|
||||
if (event.getX() > getPercent(getWidth(), 66)) {
|
||||
direction |= DIGITAL_PAD_DIRECTION_RIGHT;
|
||||
}
|
||||
if (event.getY() > getPercent(getHeight(), 66)) {
|
||||
direction |= DIGITAL_PAD_DIRECTION_DOWN;
|
||||
}
|
||||
if (event.getY() < getPercent(getHeight(), 33)) {
|
||||
direction |= DIGITAL_PAD_DIRECTION_UP;
|
||||
}
|
||||
newDirectionCallback(direction);
|
||||
invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_POINTER_UP: {
|
||||
direction = 0;
|
||||
newDirectionCallback(direction);
|
||||
invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public interface DigitalPadListener {
|
||||
void onDirectionChange(int direction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.limelight.nvstream.input.ControllerPacket;
|
||||
|
||||
public class LeftAnalogStick extends AnalogStick {
|
||||
public LeftAnalogStick(final VirtualController controller, final Context context) {
|
||||
super(controller, context, EID_LS);
|
||||
|
||||
addAnalogStickListener(new AnalogStick.AnalogStickListener() {
|
||||
@Override
|
||||
public void onMovement(float x, float y) {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.leftStickX = (short) (x * 0x7FFE);
|
||||
inputContext.leftStickY = (short) (y * 0x7FFE);
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDoubleClick() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.inputMap |= ControllerPacket.LS_CLK_FLAG;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRevoke() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.inputMap &= ~ControllerPacket.LS_CLK_FLAG;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
public class LeftTrigger extends DigitalButton {
|
||||
public LeftTrigger(final VirtualController controller, final int layer, final Context context) {
|
||||
super(controller, EID_LT, layer, context);
|
||||
addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
|
||||
@Override
|
||||
public void onClick() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.leftTrigger = (byte) 0xFF;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongClick() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRelease() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.leftTrigger = (byte) 0x00;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.limelight.nvstream.input.ControllerPacket;
|
||||
|
||||
public class RightAnalogStick extends AnalogStick {
|
||||
public RightAnalogStick(final VirtualController controller, final Context context) {
|
||||
super(controller, context, EID_RS);
|
||||
|
||||
addAnalogStickListener(new AnalogStick.AnalogStickListener() {
|
||||
@Override
|
||||
public void onMovement(float x, float y) {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.rightStickX = (short) (x * 0x7FFE);
|
||||
inputContext.rightStickY = (short) (y * 0x7FFE);
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDoubleClick() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.inputMap |= ControllerPacket.RS_CLK_FLAG;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRevoke() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.inputMap &= ~ControllerPacket.RS_CLK_FLAG;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
public class RightTrigger extends DigitalButton {
|
||||
public RightTrigger(final VirtualController controller, final int layer, final Context context) {
|
||||
super(controller, EID_RT, layer, context);
|
||||
addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
|
||||
@Override
|
||||
public void onClick() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.rightTrigger = (byte) 0xFF;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongClick() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRelease() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.rightTrigger = (byte) 0x00;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.limelight.R;
|
||||
import com.limelight.binding.input.ControllerHandler;
|
||||
import com.limelight.nvstream.NvConnection;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
public class VirtualController {
|
||||
public static class ControllerInputContext {
|
||||
public short inputMap = 0x0000;
|
||||
public byte leftTrigger = 0x00;
|
||||
public byte rightTrigger = 0x00;
|
||||
public short rightStickX = 0x0000;
|
||||
public short rightStickY = 0x0000;
|
||||
public short leftStickX = 0x0000;
|
||||
public short leftStickY = 0x0000;
|
||||
}
|
||||
|
||||
public enum ControllerMode {
|
||||
Active,
|
||||
MoveButtons,
|
||||
ResizeButtons
|
||||
}
|
||||
|
||||
private static final boolean _PRINT_DEBUG_INFORMATION = false;
|
||||
|
||||
private ControllerHandler controllerHandler;
|
||||
private Context context = null;
|
||||
|
||||
private FrameLayout frame_layout = null;
|
||||
private RelativeLayout relative_layout = null;
|
||||
|
||||
private Timer retransmitTimer;
|
||||
|
||||
ControllerMode currentMode = ControllerMode.Active;
|
||||
ControllerInputContext inputContext = new ControllerInputContext();
|
||||
|
||||
private Button buttonConfigure = null;
|
||||
|
||||
private List<VirtualControllerElement> elements = new ArrayList<>();
|
||||
|
||||
public VirtualController(final ControllerHandler controllerHandler, FrameLayout layout, final Context context) {
|
||||
this.controllerHandler = controllerHandler;
|
||||
this.frame_layout = layout;
|
||||
this.context = context;
|
||||
|
||||
relative_layout = new RelativeLayout(context);
|
||||
|
||||
frame_layout.addView(relative_layout);
|
||||
|
||||
buttonConfigure = new Button(context);
|
||||
buttonConfigure.setAlpha(0.25f);
|
||||
buttonConfigure.setFocusable(false);
|
||||
buttonConfigure.setBackgroundResource(R.drawable.ic_settings);
|
||||
buttonConfigure.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
String message;
|
||||
|
||||
if (currentMode == ControllerMode.Active){
|
||||
currentMode = ControllerMode.MoveButtons;
|
||||
message = "Entering configuration mode (Move buttons)";
|
||||
} else if (currentMode == ControllerMode.MoveButtons) {
|
||||
currentMode = ControllerMode.ResizeButtons;
|
||||
message = "Entering configuration mode (Resize buttons)";
|
||||
} else {
|
||||
currentMode = ControllerMode.Active;
|
||||
VirtualControllerConfigurationLoader.saveProfile(VirtualController.this, context);
|
||||
message = "Exiting configuration mode";
|
||||
}
|
||||
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
|
||||
|
||||
relative_layout.invalidate();
|
||||
|
||||
for (VirtualControllerElement element : elements) {
|
||||
element.invalidate();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
retransmitTimer.cancel();
|
||||
relative_layout.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
public void show() {
|
||||
relative_layout.setVisibility(View.VISIBLE);
|
||||
|
||||
// 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 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() {
|
||||
for (VirtualControllerElement element : elements) {
|
||||
relative_layout.removeView(element);
|
||||
}
|
||||
elements.clear();
|
||||
}
|
||||
|
||||
public void setOpacity(int opacity) {
|
||||
for (VirtualControllerElement element : elements) {
|
||||
element.setOpacity(opacity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void addElement(VirtualControllerElement element, int x, int y, int width, int height) {
|
||||
elements.add(element);
|
||||
RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(width, height);
|
||||
layoutParams.setMargins(x, y, 0, 0);
|
||||
|
||||
relative_layout.addView(element, layoutParams);
|
||||
}
|
||||
|
||||
public List<VirtualControllerElement> getElements() {
|
||||
return elements;
|
||||
}
|
||||
|
||||
private static final void _DBG(String text) {
|
||||
if (_PRINT_DEBUG_INFORMATION) {
|
||||
System.out.println("VirtualController: " + text);
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshLayout() {
|
||||
relative_layout.removeAllViews();
|
||||
removeElements();
|
||||
|
||||
DisplayMetrics screen = context.getResources().getDisplayMetrics();
|
||||
|
||||
int buttonSize = (int)(screen.heightPixels*0.06f);
|
||||
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(buttonSize, buttonSize);
|
||||
params.addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE);
|
||||
params.addRule(RelativeLayout.ALIGN_PARENT_TOP, RelativeLayout.TRUE);
|
||||
params.leftMargin = 15;
|
||||
params.topMargin = 15;
|
||||
relative_layout.addView(buttonConfigure, params);
|
||||
|
||||
// Start with the default layout
|
||||
VirtualControllerConfigurationLoader.createDefaultLayout(this, context);
|
||||
|
||||
// Apply user preferences onto the default layout
|
||||
VirtualControllerConfigurationLoader.loadFromPreferences(this, context);
|
||||
}
|
||||
|
||||
public ControllerMode getControllerMode() {
|
||||
return currentMode;
|
||||
}
|
||||
|
||||
public ControllerInputContext getControllerInputContext() {
|
||||
return inputContext;
|
||||
}
|
||||
|
||||
void sendControllerInputContext() {
|
||||
_DBG("INPUT_MAP + " + inputContext.inputMap);
|
||||
_DBG("LEFT_TRIGGER " + inputContext.leftTrigger);
|
||||
_DBG("RIGHT_TRIGGER " + inputContext.rightTrigger);
|
||||
_DBG("LEFT STICK X: " + inputContext.leftStickX + " Y: " + inputContext.leftStickY);
|
||||
_DBG("RIGHT STICK X: " + inputContext.rightStickX + " Y: " + inputContext.rightStickY);
|
||||
|
||||
if (controllerHandler != null) {
|
||||
controllerHandler.reportOscState(
|
||||
inputContext.inputMap,
|
||||
inputContext.leftStickX,
|
||||
inputContext.leftStickY,
|
||||
inputContext.rightStickX,
|
||||
inputContext.rightStickY,
|
||||
inputContext.leftTrigger,
|
||||
inputContext.rightTrigger
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.DisplayMetrics;
|
||||
|
||||
import com.limelight.nvstream.input.ControllerPacket;
|
||||
import com.limelight.preferences.PreferenceConfiguration;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class VirtualControllerConfigurationLoader {
|
||||
public static final String OSC_PREFERENCE = "OSC";
|
||||
|
||||
private static int getPercent(
|
||||
int percent,
|
||||
int total) {
|
||||
return (int) (((float) total / (float) 100) * (float) percent);
|
||||
}
|
||||
|
||||
// The default controls are specified using a grid of 128*72 cells at 16:9
|
||||
private static int screenScale(int units, int height) {
|
||||
return (int) (((float) height / (float) 72) * (float) units);
|
||||
}
|
||||
|
||||
private static DigitalPad createDigitalPad(
|
||||
final VirtualController controller,
|
||||
final Context context) {
|
||||
|
||||
DigitalPad digitalPad = new DigitalPad(controller, context);
|
||||
digitalPad.addDigitalPadListener(new DigitalPad.DigitalPadListener() {
|
||||
@Override
|
||||
public void onDirectionChange(int direction) {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
|
||||
if (direction == DigitalPad.DIGITAL_PAD_DIRECTION_NO_DIRECTION) {
|
||||
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;
|
||||
}
|
||||
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_RIGHT) > 0) {
|
||||
inputContext.inputMap |= ControllerPacket.RIGHT_FLAG;
|
||||
}
|
||||
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_UP) > 0) {
|
||||
inputContext.inputMap |= ControllerPacket.UP_FLAG;
|
||||
}
|
||||
if ((direction & DigitalPad.DIGITAL_PAD_DIRECTION_DOWN) > 0) {
|
||||
inputContext.inputMap |= ControllerPacket.DOWN_FLAG;
|
||||
}
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
});
|
||||
|
||||
return digitalPad;
|
||||
}
|
||||
|
||||
private static DigitalButton createDigitalButton(
|
||||
final int elementId,
|
||||
final int keyShort,
|
||||
final int keyLong,
|
||||
final int layer,
|
||||
final String text,
|
||||
final int icon,
|
||||
final VirtualController controller,
|
||||
final Context context) {
|
||||
DigitalButton button = new DigitalButton(controller, elementId, layer, context);
|
||||
button.setText(text);
|
||||
button.setIcon(icon);
|
||||
|
||||
button.addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
|
||||
@Override
|
||||
public void onClick() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.inputMap |= keyShort;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongClick() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.inputMap |= keyLong;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRelease() {
|
||||
VirtualController.ControllerInputContext inputContext =
|
||||
controller.getControllerInputContext();
|
||||
inputContext.inputMap &= ~keyShort;
|
||||
inputContext.inputMap &= ~keyLong;
|
||||
|
||||
controller.sendControllerInputContext();
|
||||
}
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private static DigitalButton createLeftTrigger(
|
||||
final int layer,
|
||||
final String text,
|
||||
final int icon,
|
||||
final VirtualController controller,
|
||||
final Context context) {
|
||||
LeftTrigger button = new LeftTrigger(controller, layer, context);
|
||||
button.setText(text);
|
||||
button.setIcon(icon);
|
||||
return button;
|
||||
}
|
||||
|
||||
private static DigitalButton createRightTrigger(
|
||||
final int layer,
|
||||
final String text,
|
||||
final int icon,
|
||||
final VirtualController controller,
|
||||
final Context context) {
|
||||
RightTrigger button = new RightTrigger(controller, layer, context);
|
||||
button.setText(text);
|
||||
button.setIcon(icon);
|
||||
return button;
|
||||
}
|
||||
|
||||
private static AnalogStick createLeftStick(
|
||||
final VirtualController controller,
|
||||
final Context context) {
|
||||
return new LeftAnalogStick(controller, context);
|
||||
}
|
||||
|
||||
private static AnalogStick createRightStick(
|
||||
final VirtualController controller,
|
||||
final Context context) {
|
||||
return new RightAnalogStick(controller, context);
|
||||
}
|
||||
|
||||
|
||||
private static final int TRIGGER_L_BASE_X = 1;
|
||||
private static final int TRIGGER_R_BASE_X = 92;
|
||||
private static final int TRIGGER_DISTANCE = 23;
|
||||
private static final int TRIGGER_BASE_Y = 31;
|
||||
private static final int TRIGGER_WIDTH = 12;
|
||||
private static final int TRIGGER_HEIGHT = 9;
|
||||
|
||||
// Face buttons are defined based on the Y button (button number 9)
|
||||
private static final int BUTTON_BASE_X = 106;
|
||||
private static final int BUTTON_BASE_Y = 1;
|
||||
private static final int BUTTON_SIZE = 10;
|
||||
|
||||
private static final int DPAD_BASE_X = 4;
|
||||
private static final int DPAD_BASE_Y = 41;
|
||||
private static final int DPAD_SIZE = 30;
|
||||
|
||||
private static final int ANALOG_L_BASE_X = 6;
|
||||
private static final int ANALOG_L_BASE_Y = 4;
|
||||
private static final int ANALOG_R_BASE_X = 98;
|
||||
private static final int ANALOG_R_BASE_Y = 42;
|
||||
private static final int ANALOG_SIZE = 26;
|
||||
|
||||
private static final int L3_R3_BASE_Y = 60;
|
||||
|
||||
private static final int START_X = 83;
|
||||
private static final int BACK_X = 34;
|
||||
private static final int START_BACK_Y = 64;
|
||||
private static final int START_BACK_WIDTH = 12;
|
||||
private static final int START_BACK_HEIGHT = 7;
|
||||
|
||||
public static void createDefaultLayout(final VirtualController controller, final Context context) {
|
||||
|
||||
DisplayMetrics screen = context.getResources().getDisplayMetrics();
|
||||
PreferenceConfiguration config = PreferenceConfiguration.readPreferences(context);
|
||||
|
||||
// Displace controls on the right by this amount of pixels to account for different aspect ratios
|
||||
int rightDisplacement = screen.widthPixels - screen.heightPixels * 16 / 9;
|
||||
|
||||
int height = screen.heightPixels;
|
||||
|
||||
// NOTE: Some of these getPercent() expressions seem like they can be combined
|
||||
// into a single call. Due to floating point rounding, this isn't actually possible.
|
||||
|
||||
if (!config.onlyL3R3)
|
||||
{
|
||||
controller.addElement(createDigitalPad(controller, context),
|
||||
screenScale(DPAD_BASE_X, height),
|
||||
screenScale(DPAD_BASE_Y, height),
|
||||
screenScale(DPAD_SIZE, height),
|
||||
screenScale(DPAD_SIZE, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_A,
|
||||
!config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1,
|
||||
!config.flipFaceButtons ? "A" : "B", -1, controller, context),
|
||||
screenScale(BUTTON_BASE_X, height) + rightDisplacement,
|
||||
screenScale(BUTTON_BASE_Y + 2 * BUTTON_SIZE, height),
|
||||
screenScale(BUTTON_SIZE, height),
|
||||
screenScale(BUTTON_SIZE, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_B,
|
||||
config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1,
|
||||
config.flipFaceButtons ? "A" : "B", -1, controller, context),
|
||||
screenScale(BUTTON_BASE_X + BUTTON_SIZE, height) + rightDisplacement,
|
||||
screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height),
|
||||
screenScale(BUTTON_SIZE, height),
|
||||
screenScale(BUTTON_SIZE, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_X,
|
||||
!config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1,
|
||||
!config.flipFaceButtons ? "X" : "Y", -1, controller, context),
|
||||
screenScale(BUTTON_BASE_X - BUTTON_SIZE, height) + rightDisplacement,
|
||||
screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height),
|
||||
screenScale(BUTTON_SIZE, height),
|
||||
screenScale(BUTTON_SIZE, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_Y,
|
||||
config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1,
|
||||
config.flipFaceButtons ? "X" : "Y", -1, controller, context),
|
||||
screenScale(BUTTON_BASE_X, height) + rightDisplacement,
|
||||
screenScale(BUTTON_BASE_Y, height),
|
||||
screenScale(BUTTON_SIZE, height),
|
||||
screenScale(BUTTON_SIZE, height)
|
||||
);
|
||||
|
||||
controller.addElement(createLeftTrigger(
|
||||
1, "LT", -1, controller, context),
|
||||
screenScale(TRIGGER_L_BASE_X, height),
|
||||
screenScale(TRIGGER_BASE_Y, height),
|
||||
screenScale(TRIGGER_WIDTH, height),
|
||||
screenScale(TRIGGER_HEIGHT, height)
|
||||
);
|
||||
|
||||
controller.addElement(createRightTrigger(
|
||||
1, "RT", -1, controller, context),
|
||||
screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement,
|
||||
screenScale(TRIGGER_BASE_Y, height),
|
||||
screenScale(TRIGGER_WIDTH, height),
|
||||
screenScale(TRIGGER_HEIGHT, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_LB,
|
||||
ControllerPacket.LB_FLAG, 0, 1, "LB", -1, controller, context),
|
||||
screenScale(TRIGGER_L_BASE_X + TRIGGER_DISTANCE, height),
|
||||
screenScale(TRIGGER_BASE_Y, height),
|
||||
screenScale(TRIGGER_WIDTH, height),
|
||||
screenScale(TRIGGER_HEIGHT, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_RB,
|
||||
ControllerPacket.RB_FLAG, 0, 1, "RB", -1, controller, context),
|
||||
screenScale(TRIGGER_R_BASE_X, height) + rightDisplacement,
|
||||
screenScale(TRIGGER_BASE_Y, height),
|
||||
screenScale(TRIGGER_WIDTH, height),
|
||||
screenScale(TRIGGER_HEIGHT, height)
|
||||
);
|
||||
|
||||
controller.addElement(createLeftStick(controller, context),
|
||||
screenScale(ANALOG_L_BASE_X, height),
|
||||
screenScale(ANALOG_L_BASE_Y, height),
|
||||
screenScale(ANALOG_SIZE, height),
|
||||
screenScale(ANALOG_SIZE, height)
|
||||
);
|
||||
|
||||
controller.addElement(createRightStick(controller, context),
|
||||
screenScale(ANALOG_R_BASE_X, height) + rightDisplacement,
|
||||
screenScale(ANALOG_R_BASE_Y, height),
|
||||
screenScale(ANALOG_SIZE, height),
|
||||
screenScale(ANALOG_SIZE, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_BACK,
|
||||
ControllerPacket.BACK_FLAG, 0, 2, "BACK", -1, controller, context),
|
||||
screenScale(BACK_X, height),
|
||||
screenScale(START_BACK_Y, height),
|
||||
screenScale(START_BACK_WIDTH, height),
|
||||
screenScale(START_BACK_HEIGHT, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_START,
|
||||
ControllerPacket.PLAY_FLAG, 0, 3, "START", -1, controller, context),
|
||||
screenScale(START_X, height) + rightDisplacement,
|
||||
screenScale(START_BACK_Y, height),
|
||||
screenScale(START_BACK_WIDTH, height),
|
||||
screenScale(START_BACK_HEIGHT, height)
|
||||
);
|
||||
}
|
||||
else {
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_LSB,
|
||||
ControllerPacket.LS_CLK_FLAG, 0, 1, "L3", -1, controller, context),
|
||||
screenScale(TRIGGER_L_BASE_X, height),
|
||||
screenScale(L3_R3_BASE_Y, height),
|
||||
screenScale(TRIGGER_WIDTH, height),
|
||||
screenScale(TRIGGER_HEIGHT, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
VirtualControllerElement.EID_RSB,
|
||||
ControllerPacket.RS_CLK_FLAG, 0, 1, "R3", -1, controller, context),
|
||||
screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement,
|
||||
screenScale(L3_R3_BASE_Y, height),
|
||||
screenScale(TRIGGER_WIDTH, height),
|
||||
screenScale(TRIGGER_HEIGHT, height)
|
||||
);
|
||||
}
|
||||
|
||||
controller.setOpacity(config.oscOpacity);
|
||||
}
|
||||
|
||||
public static void saveProfile(final VirtualController controller,
|
||||
final Context context) {
|
||||
SharedPreferences.Editor prefEditor = context.getSharedPreferences(OSC_PREFERENCE, Activity.MODE_PRIVATE).edit();
|
||||
|
||||
for (VirtualControllerElement element : controller.getElements()) {
|
||||
String prefKey = ""+element.elementId;
|
||||
try {
|
||||
prefEditor.putString(prefKey, element.getConfiguration().toString());
|
||||
} catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
prefEditor.apply();
|
||||
}
|
||||
|
||||
public static void loadFromPreferences(final VirtualController controller, final Context context) {
|
||||
SharedPreferences pref = context.getSharedPreferences(OSC_PREFERENCE, Activity.MODE_PRIVATE);
|
||||
|
||||
for (VirtualControllerElement element : controller.getElements()) {
|
||||
String prefKey = ""+element.elementId;
|
||||
|
||||
String jsonConfig = pref.getString(prefKey, null);
|
||||
if (jsonConfig != null) {
|
||||
try {
|
||||
element.loadConfiguration(new JSONObject(jsonConfig));
|
||||
} catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
|
||||
// Remove the corrupt element from the preferences
|
||||
pref.edit().remove(prefKey).apply();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Created by Karim Mreisi.
|
||||
*/
|
||||
|
||||
package com.limelight.binding.input.virtual_controller;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.RelativeLayout;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public abstract class VirtualControllerElement extends View {
|
||||
protected static boolean _PRINT_DEBUG_INFORMATION = false;
|
||||
|
||||
public static final int EID_DPAD = 1;
|
||||
public static final int EID_LT = 2;
|
||||
public static final int EID_RT = 3;
|
||||
public static final int EID_LB = 4;
|
||||
public static final int EID_RB = 5;
|
||||
public static final int EID_A = 6;
|
||||
public static final int EID_B = 7;
|
||||
public static final int EID_X = 8;
|
||||
public static final int EID_Y = 9;
|
||||
public static final int EID_BACK = 10;
|
||||
public static final int EID_START = 11;
|
||||
public static final int EID_LS = 12;
|
||||
public static final int EID_RS = 13;
|
||||
public static final int EID_LSB = 14;
|
||||
public static final int EID_RSB = 15;
|
||||
|
||||
protected VirtualController virtualController;
|
||||
protected final int elementId;
|
||||
|
||||
private final Paint paint = new Paint();
|
||||
|
||||
private int normalColor = 0xF0888888;
|
||||
protected int pressedColor = 0xF00000FF;
|
||||
private int configMoveColor = 0xF0FF0000;
|
||||
private int configResizeColor = 0xF0FF00FF;
|
||||
private int configSelectedColor = 0xF000FF00;
|
||||
|
||||
protected int startSize_x;
|
||||
protected int startSize_y;
|
||||
|
||||
float position_pressed_x = 0;
|
||||
float position_pressed_y = 0;
|
||||
|
||||
private enum Mode {
|
||||
Normal,
|
||||
Resize,
|
||||
Move
|
||||
}
|
||||
|
||||
private Mode currentMode = Mode.Normal;
|
||||
|
||||
protected VirtualControllerElement(VirtualController controller, Context context, int elementId) {
|
||||
super(context);
|
||||
|
||||
this.virtualController = controller;
|
||||
this.elementId = elementId;
|
||||
}
|
||||
|
||||
protected void moveElement(int pressed_x, int pressed_y, int x, int y) {
|
||||
int newPos_x = (int) getX() + x - pressed_x;
|
||||
int newPos_y = (int) getY() + y - pressed_y;
|
||||
|
||||
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
|
||||
|
||||
layoutParams.leftMargin = newPos_x > 0 ? newPos_x : 0;
|
||||
layoutParams.topMargin = newPos_y > 0 ? newPos_y : 0;
|
||||
layoutParams.rightMargin = 0;
|
||||
layoutParams.bottomMargin = 0;
|
||||
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
protected void resizeElement(int pressed_x, int pressed_y, int width, int height) {
|
||||
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
|
||||
|
||||
int newHeight = height + (startSize_y - pressed_y);
|
||||
int newWidth = width + (startSize_x - pressed_x);
|
||||
|
||||
layoutParams.height = newHeight > 20 ? newHeight : 20;
|
||||
layoutParams.width = newWidth > 20 ? newWidth : 20;
|
||||
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
onElementDraw(canvas);
|
||||
|
||||
if (currentMode != Mode.Normal) {
|
||||
paint.setColor(configSelectedColor);
|
||||
paint.setStrokeWidth(getDefaultStrokeWidth());
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
|
||||
canvas.drawRect(paint.getStrokeWidth(), paint.getStrokeWidth(),
|
||||
getWidth()-paint.getStrokeWidth(), getHeight()-paint.getStrokeWidth(),
|
||||
paint);
|
||||
}
|
||||
|
||||
super.onDraw(canvas);
|
||||
}
|
||||
|
||||
/*
|
||||
protected void actionShowNormalColorChooser() {
|
||||
AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() {
|
||||
@Override
|
||||
public void onCancel(AmbilWarnaDialog dialog)
|
||||
{}
|
||||
|
||||
@Override
|
||||
public void onOk(AmbilWarnaDialog dialog, int color) {
|
||||
normalColor = color;
|
||||
invalidate();
|
||||
}
|
||||
});
|
||||
colorDialog.show();
|
||||
}
|
||||
|
||||
protected void actionShowPressedColorChooser() {
|
||||
AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() {
|
||||
@Override
|
||||
public void onCancel(AmbilWarnaDialog dialog) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOk(AmbilWarnaDialog dialog, int color) {
|
||||
pressedColor = color;
|
||||
invalidate();
|
||||
}
|
||||
});
|
||||
colorDialog.show();
|
||||
}
|
||||
*/
|
||||
|
||||
protected void actionEnableMove() {
|
||||
currentMode = Mode.Move;
|
||||
}
|
||||
|
||||
protected void actionEnableResize() {
|
||||
currentMode = Mode.Resize;
|
||||
}
|
||||
|
||||
protected void actionCancel() {
|
||||
currentMode = Mode.Normal;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
protected int getDefaultColor() {
|
||||
if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons)
|
||||
return configMoveColor;
|
||||
else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)
|
||||
return configResizeColor;
|
||||
else
|
||||
return normalColor;
|
||||
}
|
||||
|
||||
protected int getDefaultStrokeWidth() {
|
||||
DisplayMetrics screen = getResources().getDisplayMetrics();
|
||||
return (int)(screen.heightPixels*0.004f);
|
||||
}
|
||||
|
||||
protected void showConfigurationDialog() {
|
||||
AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext());
|
||||
|
||||
alertBuilder.setTitle("Configuration");
|
||||
|
||||
CharSequence functions[] = new CharSequence[]{
|
||||
"Move",
|
||||
"Resize",
|
||||
/*election
|
||||
"Set n
|
||||
Disable color sormal color",
|
||||
"Set pressed color",
|
||||
*/
|
||||
"Cancel"
|
||||
};
|
||||
|
||||
alertBuilder.setItems(functions, new DialogInterface.OnClickListener() {
|
||||
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
switch (which) {
|
||||
case 0: { // move
|
||||
actionEnableMove();
|
||||
break;
|
||||
}
|
||||
case 1: { // resize
|
||||
actionEnableResize();
|
||||
break;
|
||||
}
|
||||
/*
|
||||
case 2: { // set default color
|
||||
actionShowNormalColorChooser();
|
||||
break;
|
||||
}
|
||||
case 3: { // set pressed color
|
||||
actionShowPressedColorChooser();
|
||||
break;
|
||||
}
|
||||
*/
|
||||
default: { // cancel
|
||||
actionCancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
AlertDialog alert = alertBuilder.create();
|
||||
// show menu
|
||||
alert.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
if (virtualController.getControllerMode() == VirtualController.ControllerMode.Active) {
|
||||
return onElementTouchEvent(event);
|
||||
}
|
||||
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
case MotionEvent.ACTION_POINTER_DOWN: {
|
||||
position_pressed_x = event.getX();
|
||||
position_pressed_y = event.getY();
|
||||
startSize_x = getWidth();
|
||||
startSize_y = getHeight();
|
||||
|
||||
if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons)
|
||||
actionEnableMove();
|
||||
else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)
|
||||
actionEnableResize();
|
||||
|
||||
return true;
|
||||
}
|
||||
case MotionEvent.ACTION_MOVE: {
|
||||
switch (currentMode) {
|
||||
case Move: {
|
||||
moveElement(
|
||||
(int) position_pressed_x,
|
||||
(int) position_pressed_y,
|
||||
(int) event.getX(),
|
||||
(int) event.getY());
|
||||
break;
|
||||
}
|
||||
case Resize: {
|
||||
resizeElement(
|
||||
(int) position_pressed_x,
|
||||
(int) position_pressed_y,
|
||||
(int) event.getX(),
|
||||
(int) event.getY());
|
||||
break;
|
||||
}
|
||||
case Normal: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_POINTER_UP: {
|
||||
actionCancel();
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
abstract protected void onElementDraw(Canvas canvas);
|
||||
|
||||
abstract public boolean onElementTouchEvent(MotionEvent event);
|
||||
|
||||
protected static final void _DBG(String text) {
|
||||
if (_PRINT_DEBUG_INFORMATION) {
|
||||
System.out.println(text);
|
||||
}
|
||||
}
|
||||
|
||||
public void setColors(int normalColor, int pressedColor) {
|
||||
this.normalColor = normalColor;
|
||||
this.pressedColor = pressedColor;
|
||||
|
||||
invalidate();
|
||||
}
|
||||
|
||||
|
||||
public void setOpacity(int opacity) {
|
||||
int hexOpacity = opacity * 255 / 100;
|
||||
this.normalColor = (hexOpacity << 24) | (normalColor & 0x00FFFFFF);
|
||||
this.pressedColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF);
|
||||
|
||||
invalidate();
|
||||
}
|
||||
|
||||
protected final float getPercent(float value, float percent) {
|
||||
return value / 100 * percent;
|
||||
}
|
||||
|
||||
protected final int getCorrectWidth() {
|
||||
return getWidth() > getHeight() ? getHeight() : getWidth();
|
||||
}
|
||||
|
||||
|
||||
public JSONObject getConfiguration() throws JSONException {
|
||||
JSONObject configuration = new JSONObject();
|
||||
|
||||
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
|
||||
|
||||
configuration.put("LEFT", layoutParams.leftMargin);
|
||||
configuration.put("TOP", layoutParams.topMargin);
|
||||
configuration.put("WIDTH", layoutParams.width);
|
||||
configuration.put("HEIGHT", layoutParams.height);
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
public void loadConfiguration(JSONObject configuration) throws JSONException {
|
||||
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
|
||||
|
||||
layoutParams.leftMargin = configuration.getInt("LEFT");
|
||||
layoutParams.topMargin = configuration.getInt("TOP");
|
||||
layoutParams.width = configuration.getInt("WIDTH");
|
||||
layoutParams.height = configuration.getInt("HEIGHT");
|
||||
|
||||
requestLayout();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.limelight.binding.video;
|
||||
|
||||
public interface CrashListener {
|
||||
void notifyCrash(Exception e);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,762 @@
|
||||
package com.limelight.binding.video;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.ActivityManager;
|
||||
import android.content.Context;
|
||||
import android.content.pm.ConfigurationInfo;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaCodecList;
|
||||
import android.media.MediaCodecInfo.CodecCapabilities;
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel;
|
||||
import android.os.Build;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
|
||||
public class MediaCodecHelper {
|
||||
|
||||
private static final List<String> preferredDecoders;
|
||||
|
||||
private static final List<String> blacklistedDecoderPrefixes;
|
||||
private static final List<String> spsFixupBitstreamFixupDecoderPrefixes;
|
||||
private static final List<String> blacklistedAdaptivePlaybackPrefixes;
|
||||
private static final List<String> deprioritizedHevcDecoders;
|
||||
private static final List<String> baselineProfileHackPrefixes;
|
||||
private static final List<String> directSubmitPrefixes;
|
||||
private static final List<String> constrainedHighProfilePrefixes;
|
||||
private static final List<String> whitelistedHevcDecoders;
|
||||
private static final List<String> refFrameInvalidationAvcPrefixes;
|
||||
private static final List<String> refFrameInvalidationHevcPrefixes;
|
||||
private static final List<String> blacklisted49FpsDecoderPrefixes;
|
||||
private static final List<String> blacklisted59FpsDecoderPrefixes;
|
||||
private static final List<String> qualcommDecoderPrefixes;
|
||||
|
||||
private static boolean isLowEndSnapdragon = false;
|
||||
private static boolean isAdreno620 = false;
|
||||
private static boolean initialized = false;
|
||||
|
||||
static {
|
||||
directSubmitPrefixes = new LinkedList<>();
|
||||
|
||||
// These decoders have low enough input buffer latency that they
|
||||
// can be directly invoked from the receive thread
|
||||
directSubmitPrefixes.add("omx.qcom");
|
||||
directSubmitPrefixes.add("omx.sec");
|
||||
directSubmitPrefixes.add("omx.exynos");
|
||||
directSubmitPrefixes.add("omx.intel");
|
||||
directSubmitPrefixes.add("omx.brcm");
|
||||
directSubmitPrefixes.add("omx.TI");
|
||||
directSubmitPrefixes.add("omx.arc");
|
||||
directSubmitPrefixes.add("omx.nvidia");
|
||||
|
||||
// All Codec2 decoders
|
||||
directSubmitPrefixes.add("c2.");
|
||||
}
|
||||
|
||||
static {
|
||||
refFrameInvalidationAvcPrefixes = new LinkedList<>();
|
||||
refFrameInvalidationHevcPrefixes = new LinkedList<>();
|
||||
|
||||
// Qualcomm and NVIDIA may be added at runtime
|
||||
}
|
||||
|
||||
static {
|
||||
preferredDecoders = new LinkedList<>();
|
||||
}
|
||||
|
||||
static {
|
||||
blacklistedDecoderPrefixes = new LinkedList<>();
|
||||
|
||||
// Blacklist software decoders that don't support H264 high profile,
|
||||
// but exclude the official AOSP and CrOS emulator from this restriction.
|
||||
if (!Build.HARDWARE.equals("ranchu") && !Build.HARDWARE.equals("cheets")) {
|
||||
blacklistedDecoderPrefixes.add("omx.google");
|
||||
blacklistedDecoderPrefixes.add("AVCDecoder");
|
||||
}
|
||||
|
||||
// We want to avoid ffmpeg decoders since they're software decoders,
|
||||
// but on Android-x86 they might be all we have (and also relatively
|
||||
// performant on a modern x86 processor).
|
||||
if (!Build.BRAND.equals("Android-x86")) {
|
||||
blacklistedDecoderPrefixes.add("OMX.ffmpeg");
|
||||
}
|
||||
|
||||
// Force these decoders disabled because:
|
||||
// 1) They are software decoders, so the performance is terrible
|
||||
// 2) They crash with our HEVC stream anyway (at least prior to CSD batching)
|
||||
blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevcswvdec");
|
||||
blacklistedDecoderPrefixes.add("OMX.SEC.hevc.sw.dec");
|
||||
}
|
||||
|
||||
static {
|
||||
// If a decoder qualifies for reference frame invalidation,
|
||||
// these entries will be ignored for those decoders.
|
||||
spsFixupBitstreamFixupDecoderPrefixes = new LinkedList<>();
|
||||
spsFixupBitstreamFixupDecoderPrefixes.add("omx.nvidia");
|
||||
spsFixupBitstreamFixupDecoderPrefixes.add("omx.qcom");
|
||||
spsFixupBitstreamFixupDecoderPrefixes.add("omx.brcm");
|
||||
|
||||
baselineProfileHackPrefixes = new LinkedList<>();
|
||||
baselineProfileHackPrefixes.add("omx.intel");
|
||||
|
||||
blacklistedAdaptivePlaybackPrefixes = new LinkedList<>();
|
||||
// The Intel decoder on Lollipop on Nexus Player would increase latency badly
|
||||
// if adaptive playback was enabled so let's avoid it to be safe.
|
||||
blacklistedAdaptivePlaybackPrefixes.add("omx.intel");
|
||||
// The MediaTek decoder crashes at 1080p when adaptive playback is enabled
|
||||
// on some Android TV devices with H.265 only.
|
||||
blacklistedAdaptivePlaybackPrefixes.add("omx.mtk");
|
||||
|
||||
constrainedHighProfilePrefixes = new LinkedList<>();
|
||||
constrainedHighProfilePrefixes.add("omx.intel");
|
||||
}
|
||||
|
||||
static {
|
||||
whitelistedHevcDecoders = new LinkedList<>();
|
||||
|
||||
// Allow software HEVC decoding in the official AOSP emulator
|
||||
if (Build.HARDWARE.equals("ranchu")) {
|
||||
whitelistedHevcDecoders.add("omx.google");
|
||||
}
|
||||
|
||||
// Exynos seems to be the only HEVC decoder that works reliably
|
||||
whitelistedHevcDecoders.add("omx.exynos");
|
||||
|
||||
// On Darcy (Shield 2017), HEVC runs fine with no fixups required.
|
||||
// For some reason, other X1 implementations require bitstream fixups.
|
||||
if (Build.DEVICE.equalsIgnoreCase("darcy")) {
|
||||
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
|
||||
// on several configurations (> 60 FPS and 1440p) that work with HEVC, so we'll whitelist those devices for HEVC.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Build.DEVICE.startsWith("BRAVIA_")) {
|
||||
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. HEVC is much lower latency than H.264 on Sabrina (S905X2).
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
whitelistedHevcDecoders.add("omx.amlogic");
|
||||
}
|
||||
|
||||
// These theoretically have good HEVC decoding capabilities (potentially better than
|
||||
// their AVC decoders), but haven't been tested enough
|
||||
//whitelistedHevcDecoders.add("omx.rk");
|
||||
|
||||
// Let's see if HEVC decoders are finally stable with C2
|
||||
whitelistedHevcDecoders.add("c2.");
|
||||
|
||||
// Based on GPU attributes queried at runtime, the omx.qcom/c2.qti prefix will be added
|
||||
// during initialization to avoid SoCs with broken HEVC decoders.
|
||||
}
|
||||
|
||||
static {
|
||||
deprioritizedHevcDecoders = new LinkedList<>();
|
||||
|
||||
// These are decoders that work but aren't used by default for various reasons.
|
||||
|
||||
// Qualcomm is currently the only decoders in this group.
|
||||
}
|
||||
|
||||
static {
|
||||
blacklisted49FpsDecoderPrefixes = new LinkedList<>();
|
||||
blacklisted59FpsDecoderPrefixes = new LinkedList<>();
|
||||
|
||||
// We see a bunch of crashes on MediaTek Android TVs running
|
||||
// at 49 FPS (PAL 50 Hz - 1). Blacklist this frame rate for
|
||||
// these devices and hope they fix it in Pie.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
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 {
|
||||
qualcommDecoderPrefixes = new LinkedList<>();
|
||||
|
||||
qualcommDecoderPrefixes.add("omx.qcom");
|
||||
qualcommDecoderPrefixes.add("c2.qti");
|
||||
}
|
||||
|
||||
private static boolean isPowerVR(String glRenderer) {
|
||||
return glRenderer.toLowerCase().contains("powervr");
|
||||
}
|
||||
|
||||
private static String getAdrenoVersionString(String glRenderer) {
|
||||
glRenderer = glRenderer.toLowerCase().trim();
|
||||
|
||||
if (!glRenderer.contains("adreno")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Pattern modelNumberPattern = Pattern.compile("(.*)([0-9]{3})(.*)");
|
||||
|
||||
Matcher matcher = modelNumberPattern.matcher(glRenderer);
|
||||
if (!matcher.matches()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String modelNumber = matcher.group(2);
|
||||
LimeLog.info("Found Adreno GPU: "+modelNumber);
|
||||
return modelNumber;
|
||||
}
|
||||
|
||||
private static boolean isLowEndSnapdragonRenderer(String glRenderer) {
|
||||
String modelNumber = getAdrenoVersionString(glRenderer);
|
||||
if (modelNumber == null) {
|
||||
// Not an Adreno GPU
|
||||
return false;
|
||||
}
|
||||
|
||||
// The current logic is to identify low-end SoCs based on a zero in the x0x place.
|
||||
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
|
||||
// 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
|
||||
// Snapdragon 616 SoC (Adreno 405).
|
||||
private static boolean isGLES31SnapdragonRenderer(String glRenderer) {
|
||||
// Snapdragon 4xx and higher support GLES 3.1
|
||||
return getAdrenoRendererModelNumber(glRenderer) >= 400;
|
||||
}
|
||||
|
||||
public static void initialize(Context context, String glRenderer) {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
ActivityManager activityManager =
|
||||
(ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
ConfigurationInfo configInfo = activityManager.getDeviceConfigurationInfo();
|
||||
if (configInfo.reqGlEsVersion != ConfigurationInfo.GL_ES_VERSION_UNDEFINED) {
|
||||
LimeLog.info("OpenGL ES version: "+configInfo.reqGlEsVersion);
|
||||
|
||||
isLowEndSnapdragon = isLowEndSnapdragonRenderer(glRenderer);
|
||||
isAdreno620 = getAdrenoRendererModelNumber(glRenderer) == 620;
|
||||
|
||||
// Tegra K1 and later can do reference frame invalidation properly
|
||||
if (configInfo.reqGlEsVersion >= 0x30000) {
|
||||
LimeLog.info("Added omx.nvidia to AVC reference frame invalidation support list");
|
||||
refFrameInvalidationAvcPrefixes.add("omx.nvidia");
|
||||
|
||||
LimeLog.info("Added omx.qcom/c2.qti to AVC reference frame invalidation support list");
|
||||
refFrameInvalidationAvcPrefixes.add("omx.qcom");
|
||||
refFrameInvalidationAvcPrefixes.add("c2.qti");
|
||||
|
||||
// Prior to M, we were tricking the decoder into using baseline profile, which
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
// Qualcomm's early HEVC decoders break hard on our HEVC stream. The best check to
|
||||
// tell the good from the bad decoders are the generation of Adreno GPU included:
|
||||
// 3xx - bad
|
||||
// 4xx - good
|
||||
//
|
||||
// The "good" GPUs support GLES 3.1, but we can't just check that directly
|
||||
// (see comment on isGLES31SnapdragonRenderer).
|
||||
//
|
||||
if (isGLES31SnapdragonRenderer(glRenderer)) {
|
||||
// We prefer reference frame invalidation support (which is only doable on AVC on
|
||||
// older Qualcomm chips) vs. enabling HEVC by default. The user can override using the settings
|
||||
// to force HEVC on. If HDR or mobile data will be used, we'll override this and use
|
||||
// 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 {
|
||||
blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevc");
|
||||
}
|
||||
|
||||
// Older MediaTek SoCs have issues with HEVC rendering but the newer chips with
|
||||
// PowerVR GPUs have good HEVC support.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isPowerVR(glRenderer)) {
|
||||
LimeLog.info("Added omx.mtk to HEVC decoders based on PowerVR GPU");
|
||||
whitelistedHevcDecoders.add("omx.mtk");
|
||||
|
||||
// This SoC (MT8176 in GPD XD+) supports AVC RFI too, but the maxNumReferenceFrames setting
|
||||
// required to make it work adds a huge amount of latency. However, RFI on HEVC causes
|
||||
// decoder hangs on the newer GE8100, GE8300, and GE8320 GPUs, so we limit it to the
|
||||
// Series6XT GPUs where we know it works.
|
||||
if (glRenderer.contains("GX6")) {
|
||||
LimeLog.info("Added omx.mtk to RFI list for HEVC");
|
||||
refFrameInvalidationHevcPrefixes.add("omx.mtk");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
private static boolean isDecoderInList(List<String> decoderList, String decoderName) {
|
||||
if (!initialized) {
|
||||
throw new IllegalStateException("MediaCodecHelper must be initialized before use");
|
||||
}
|
||||
|
||||
for (String badPrefix : decoderList) {
|
||||
if (decoderName.length() >= badPrefix.length()) {
|
||||
String prefix = decoderName.substring(0, badPrefix.length());
|
||||
if (prefix.equalsIgnoreCase(badPrefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static long getMonotonicMillis() {
|
||||
return System.nanoTime() / 1000000L;
|
||||
}
|
||||
|
||||
public static boolean decoderSupportsLowLatency(MediaCodecInfo decoderInfo, String mimeType) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
try {
|
||||
if (decoderInfo.getCapabilitiesForType(mimeType).isFeatureSupported(CodecCapabilities.FEATURE_LowLatency)) {
|
||||
LimeLog.info("Low latency decoding mode supported (FEATURE_LowLatency)");
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Tolerate buggy codecs
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public 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 decoderSupportsAdaptivePlayback(MediaCodecInfo decoderInfo, String mimeType) {
|
||||
// Possibly enable adaptive playback on KitKat and above
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
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 decoderSupportsQcomVendorLowLatency(String decoderName) {
|
||||
// MediaCodec vendor extension support was introduced in Android 8.0:
|
||||
// https://cs.android.com/android/_/android/platform/frameworks/av/+/01c10f8cdcd58d1e7025f426a72e6e75ba5d7fc2
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
isDecoderInList(qualcommDecoderPrefixes, decoderName);
|
||||
}
|
||||
|
||||
public static boolean decoderNeedsConstrainedHighProfile(String decoderName) {
|
||||
return isDecoderInList(constrainedHighProfilePrefixes, decoderName);
|
||||
}
|
||||
|
||||
public static boolean decoderCanDirectSubmit(String decoderName) {
|
||||
return isDecoderInList(directSubmitPrefixes, decoderName) && !isExynos4Device();
|
||||
}
|
||||
|
||||
public static boolean decoderNeedsSpsBitstreamRestrictions(String decoderName) {
|
||||
return isDecoderInList(spsFixupBitstreamFixupDecoderPrefixes, decoderName);
|
||||
}
|
||||
|
||||
public static boolean decoderNeedsBaselineSpsHack(String decoderName) {
|
||||
return isDecoderInList(baselineProfileHackPrefixes, decoderName);
|
||||
}
|
||||
|
||||
public static boolean decoderBlacklistedForFrameRate(String decoderName, int fps) {
|
||||
if (fps == 49) {
|
||||
return isDecoderInList(blacklisted49FpsDecoderPrefixes, decoderName);
|
||||
}
|
||||
else if (fps == 59) {
|
||||
return isDecoderInList(blacklisted59FpsDecoderPrefixes, decoderName);
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean decoderSupportsRefFrameInvalidationAvc(String decoderName, int videoHeight) {
|
||||
// Reference frame invalidation is broken on low-end Snapdragon SoCs at 1080p.
|
||||
if (videoHeight > 720 && isLowEndSnapdragon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This device seems to crash constantly at 720p, so try disabling
|
||||
// RFI to see if we can get that under control.
|
||||
if (Build.DEVICE.equals("b3") || Build.DEVICE.equals("b5")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isDecoderInList(refFrameInvalidationAvcPrefixes, decoderName);
|
||||
}
|
||||
|
||||
public static boolean decoderSupportsRefFrameInvalidationHevc(String decoderName) {
|
||||
return isDecoderInList(refFrameInvalidationHevcPrefixes, decoderName);
|
||||
}
|
||||
|
||||
public static boolean decoderIsWhitelistedForHevc(String decoderName, boolean meteredData) {
|
||||
// TODO: Shield Tablet K1/LTE?
|
||||
//
|
||||
// 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 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
|
||||
// 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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//
|
||||
// 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 (decoderName.contains("sw")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some devices have HEVC decoders that we prefer not to use
|
||||
// typically because it can't support reference frame invalidation.
|
||||
// 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")
|
||||
@SuppressLint("NewApi")
|
||||
private static LinkedList<MediaCodecInfo> getMediaCodecList() {
|
||||
LinkedList<MediaCodecInfo> infoList = new LinkedList<>();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
MediaCodecList mcl = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
|
||||
Collections.addAll(infoList, mcl.getCodecInfos());
|
||||
}
|
||||
else {
|
||||
for (int i = 0; i < MediaCodecList.getCodecCount(); i++) {
|
||||
infoList.add(MediaCodecList.getCodecInfoAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
return infoList;
|
||||
}
|
||||
|
||||
@SuppressWarnings("RedundantThrows")
|
||||
public static String dumpDecoders() throws Exception {
|
||||
String str = "";
|
||||
for (MediaCodecInfo codecInfo : getMediaCodecList()) {
|
||||
// Skip encoders
|
||||
if (codecInfo.isEncoder()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
str += "Decoder: "+codecInfo.getName()+"\n";
|
||||
for (String type : codecInfo.getSupportedTypes()) {
|
||||
str += "\t"+type+"\n";
|
||||
CodecCapabilities caps = codecInfo.getCapabilitiesForType(type);
|
||||
|
||||
for (CodecProfileLevel profile : caps.profileLevels) {
|
||||
str += "\t\t"+profile.profile+" "+profile.level+"\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
private static MediaCodecInfo findPreferredDecoder() {
|
||||
// This is a different algorithm than the other findXXXDecoder functions,
|
||||
// because we want to evaluate the decoders in our list's order
|
||||
// rather than MediaCodecList's order
|
||||
|
||||
if (!initialized) {
|
||||
throw new IllegalStateException("MediaCodecHelper must be initialized before use");
|
||||
}
|
||||
|
||||
for (String preferredDecoder : preferredDecoders) {
|
||||
for (MediaCodecInfo codecInfo : getMediaCodecList()) {
|
||||
// Skip encoders
|
||||
if (codecInfo.isEncoder()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip compatibility aliases on Q+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (codecInfo.isAlias()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for preferred decoders
|
||||
if (preferredDecoder.equalsIgnoreCase(codecInfo.getName())) {
|
||||
LimeLog.info("Preferred decoder choice is "+codecInfo.getName());
|
||||
return codecInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isCodecBlacklisted(MediaCodecInfo codecInfo) {
|
||||
// Use the new isSoftwareOnly() function on Android Q
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (codecInfo.isSoftwareOnly()) {
|
||||
LimeLog.info("Skipping software-only decoder: "+codecInfo.getName());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for explicitly blacklisted decoders
|
||||
if (isDecoderInList(blacklistedDecoderPrefixes, codecInfo.getName())) {
|
||||
LimeLog.info("Skipping blacklisted decoder: "+codecInfo.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static MediaCodecInfo findFirstDecoder(String mimeType) {
|
||||
for (MediaCodecInfo codecInfo : getMediaCodecList()) {
|
||||
// Skip encoders
|
||||
if (codecInfo.isEncoder()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip compatibility aliases on Q+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (codecInfo.isAlias()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find a decoder that supports the specified video format
|
||||
for (String mime : codecInfo.getSupportedTypes()) {
|
||||
if (mime.equalsIgnoreCase(mimeType)) {
|
||||
// Skip blacklisted codecs
|
||||
if (isCodecBlacklisted(codecInfo)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
LimeLog.info("First decoder choice is "+codecInfo.getName());
|
||||
return codecInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static MediaCodecInfo findProbableSafeDecoder(String mimeType, int requiredProfile) {
|
||||
// First look for a preferred decoder by name
|
||||
MediaCodecInfo info = findPreferredDecoder();
|
||||
if (info != null) {
|
||||
return info;
|
||||
}
|
||||
|
||||
// Now look for decoders we know are safe
|
||||
try {
|
||||
// If this function completes, it will determine if the decoder is safe
|
||||
return findKnownSafeDecoder(mimeType, requiredProfile);
|
||||
} catch (Exception e) {
|
||||
// Some buggy devices seem to throw exceptions
|
||||
// from getCapabilitiesForType() so we'll just assume
|
||||
// they're okay and go with the first one we find
|
||||
return findFirstDecoder(mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
// We declare this method as explicitly throwing Exception
|
||||
// since some bad decoders can throw IllegalArgumentExceptions unexpectedly
|
||||
// and we want to be sure all callers are handling this possibility
|
||||
@SuppressWarnings("RedundantThrows")
|
||||
private static MediaCodecInfo findKnownSafeDecoder(String mimeType, int requiredProfile) throws Exception {
|
||||
for (MediaCodecInfo codecInfo : getMediaCodecList()) {
|
||||
// Skip encoders
|
||||
if (codecInfo.isEncoder()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip compatibility aliases on Q+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (codecInfo.isAlias()) {
|
||||
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
|
||||
if (isCodecBlacklisted(codecInfo)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CodecCapabilities caps = codecInfo.getCapabilitiesForType(mime);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String readCpuinfo() throws Exception {
|
||||
StringBuilder cpuInfo = new StringBuilder();
|
||||
BufferedReader br = new BufferedReader(new FileReader(new File("/proc/cpuinfo")));
|
||||
try {
|
||||
for (;;) {
|
||||
int ch = br.read();
|
||||
if (ch == -1)
|
||||
break;
|
||||
cpuInfo.append((char)ch);
|
||||
}
|
||||
|
||||
return cpuInfo.toString();
|
||||
} finally {
|
||||
br.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean stringContainsIgnoreCase(String string, String substring) {
|
||||
return string.toLowerCase(Locale.ENGLISH).contains(substring.toLowerCase(Locale.ENGLISH));
|
||||
}
|
||||
|
||||
public static boolean isExynos4Device() {
|
||||
try {
|
||||
// Try reading CPU info too look for
|
||||
String cpuInfo = readCpuinfo();
|
||||
|
||||
// SMDK4xxx is Exynos 4
|
||||
if (stringContainsIgnoreCase(cpuInfo, "SMDK4")) {
|
||||
LimeLog.info("Found SMDK4 in /proc/cpuinfo");
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we see "Exynos 4" also we'll count it
|
||||
if (stringContainsIgnoreCase(cpuInfo, "Exynos 4")) {
|
||||
LimeLog.info("Found Exynos 4 in /proc/cpuinfo");
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
try {
|
||||
File systemDir = new File("/sys/devices/system");
|
||||
File[] files = systemDir.listFiles();
|
||||
if (files != null) {
|
||||
for (File f : files) {
|
||||
if (stringContainsIgnoreCase(f.getName(), "exynos4")) {
|
||||
LimeLog.info("Found exynos4 in /sys/devices/system");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.limelight.binding.video;
|
||||
|
||||
public interface PerfOverlayListener {
|
||||
void onPerfUpdate(final String text);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.limelight.binding.video;
|
||||
|
||||
import android.os.SystemClock;
|
||||
|
||||
class VideoStats {
|
||||
|
||||
long decoderTimeMs;
|
||||
long totalTimeMs;
|
||||
int totalFrames;
|
||||
int totalFramesReceived;
|
||||
int totalFramesRendered;
|
||||
int frameLossEvents;
|
||||
int framesLost;
|
||||
long measurementStartTimestamp;
|
||||
|
||||
void add(VideoStats other) {
|
||||
this.decoderTimeMs += other.decoderTimeMs;
|
||||
this.totalTimeMs += other.totalTimeMs;
|
||||
this.totalFrames += other.totalFrames;
|
||||
this.totalFramesReceived += other.totalFramesReceived;
|
||||
this.totalFramesRendered += other.totalFramesRendered;
|
||||
this.frameLossEvents += other.frameLossEvents;
|
||||
this.framesLost += other.framesLost;
|
||||
|
||||
if (this.measurementStartTimestamp == 0) {
|
||||
this.measurementStartTimestamp = other.measurementStartTimestamp;
|
||||
}
|
||||
|
||||
assert other.measurementStartTimestamp >= this.measurementStartTimestamp;
|
||||
}
|
||||
|
||||
void copy(VideoStats other) {
|
||||
this.decoderTimeMs = other.decoderTimeMs;
|
||||
this.totalTimeMs = other.totalTimeMs;
|
||||
this.totalFrames = other.totalFrames;
|
||||
this.totalFramesReceived = other.totalFramesReceived;
|
||||
this.totalFramesRendered = other.totalFramesRendered;
|
||||
this.frameLossEvents = other.frameLossEvents;
|
||||
this.framesLost = other.framesLost;
|
||||
this.measurementStartTimestamp = other.measurementStartTimestamp;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
this.decoderTimeMs = 0;
|
||||
this.totalTimeMs = 0;
|
||||
this.totalFrames = 0;
|
||||
this.totalFramesReceived = 0;
|
||||
this.totalFramesRendered = 0;
|
||||
this.frameLossEvents = 0;
|
||||
this.framesLost = 0;
|
||||
this.measurementStartTimestamp = 0;
|
||||
}
|
||||
|
||||
VideoStatsFps getFps() {
|
||||
float elapsed = (SystemClock.uptimeMillis() - this.measurementStartTimestamp) / (float) 1000;
|
||||
|
||||
VideoStatsFps fps = new VideoStatsFps();
|
||||
if (elapsed > 0) {
|
||||
fps.totalFps = this.totalFrames / elapsed;
|
||||
fps.receivedFps = this.totalFramesReceived / elapsed;
|
||||
fps.renderedFps = this.totalFramesRendered / elapsed;
|
||||
}
|
||||
return fps;
|
||||
}
|
||||
}
|
||||
|
||||
class VideoStatsFps {
|
||||
|
||||
float totalFps;
|
||||
float receivedFps;
|
||||
float renderedFps;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.limelight.computers;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteException;
|
||||
|
||||
public class ComputerDatabaseManager {
|
||||
private static final String COMPUTER_DB_NAME = "computers3.db";
|
||||
private static final String COMPUTER_TABLE_NAME = "Computers";
|
||||
private static final String COMPUTER_UUID_COLUMN_NAME = "UUID";
|
||||
private static final String COMPUTER_NAME_COLUMN_NAME = "ComputerName";
|
||||
private static final String ADDRESSES_COLUMN_NAME = "Addresses";
|
||||
private static final String MAC_ADDRESS_COLUMN_NAME = "MacAddress";
|
||||
private static final String SERVER_CERT_COLUMN_NAME = "ServerCert";
|
||||
|
||||
private static final char ADDRESS_DELIMITER = ';';
|
||||
|
||||
private SQLiteDatabase computerDb;
|
||||
|
||||
public ComputerDatabaseManager(Context c) {
|
||||
try {
|
||||
// Create or open an existing DB
|
||||
computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null);
|
||||
} catch (SQLiteException e) {
|
||||
// Delete the DB and try again
|
||||
c.deleteDatabase(COMPUTER_DB_NAME);
|
||||
computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null);
|
||||
}
|
||||
initializeDb(c);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
computerDb.close();
|
||||
}
|
||||
|
||||
private void initializeDb(Context c) {
|
||||
// Create tables if they aren't already there
|
||||
computerDb.execSQL(String.format((Locale)null,
|
||||
"CREATE TABLE IF NOT EXISTS %s(%s TEXT PRIMARY KEY, %s TEXT NOT NULL, %s TEXT NOT NULL, %s TEXT, %s TEXT)",
|
||||
COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME, COMPUTER_NAME_COLUMN_NAME,
|
||||
ADDRESSES_COLUMN_NAME, MAC_ADDRESS_COLUMN_NAME, SERVER_CERT_COLUMN_NAME));
|
||||
|
||||
// Move all computers from the old DB (if any) to the new one
|
||||
List<ComputerDetails> oldComputers = LegacyDatabaseReader.migrateAllComputers(c);
|
||||
for (ComputerDetails computer : oldComputers) {
|
||||
updateComputer(computer);
|
||||
}
|
||||
oldComputers = LegacyDatabaseReader2.migrateAllComputers(c);
|
||||
for (ComputerDetails computer : oldComputers) {
|
||||
updateComputer(computer);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteComputer(ComputerDetails details) {
|
||||
computerDb.delete(COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME+"=?", new String[]{details.uuid});
|
||||
}
|
||||
|
||||
public boolean updateComputer(ComputerDetails details) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(COMPUTER_UUID_COLUMN_NAME, details.uuid);
|
||||
values.put(COMPUTER_NAME_COLUMN_NAME, details.name);
|
||||
|
||||
StringBuilder addresses = new StringBuilder();
|
||||
addresses.append(details.localAddress != null ? details.localAddress : "");
|
||||
addresses.append(ADDRESS_DELIMITER).append(details.remoteAddress != null ? details.remoteAddress : "");
|
||||
addresses.append(ADDRESS_DELIMITER).append(details.manualAddress != null ? details.manualAddress : "");
|
||||
addresses.append(ADDRESS_DELIMITER).append(details.ipv6Address != null ? details.ipv6Address : "");
|
||||
|
||||
values.put(ADDRESSES_COLUMN_NAME, addresses.toString());
|
||||
values.put(MAC_ADDRESS_COLUMN_NAME, details.macAddress);
|
||||
try {
|
||||
if (details.serverCert != null) {
|
||||
values.put(SERVER_CERT_COLUMN_NAME, details.serverCert.getEncoded());
|
||||
}
|
||||
else {
|
||||
values.put(SERVER_CERT_COLUMN_NAME, (byte[])null);
|
||||
}
|
||||
} catch (CertificateEncodingException e) {
|
||||
values.put(SERVER_CERT_COLUMN_NAME, (byte[])null);
|
||||
e.printStackTrace();
|
||||
}
|
||||
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) {
|
||||
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 = readNonEmptyString(addresses[0]);
|
||||
details.remoteAddress = readNonEmptyString(addresses[1]);
|
||||
details.manualAddress = readNonEmptyString(addresses[2]);
|
||||
details.ipv6Address = readNonEmptyString(addresses[3]);
|
||||
|
||||
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 List<ComputerDetails> getAllComputers() {
|
||||
Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null);
|
||||
LinkedList<ComputerDetails> computerList = new LinkedList<>();
|
||||
while (c.moveToNext()) {
|
||||
computerList.add(getComputerFromCursor(c));
|
||||
}
|
||||
|
||||
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);
|
||||
if (!c.moveToFirst()) {
|
||||
// No matching computer
|
||||
c.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
ComputerDetails details = getComputerFromCursor(c);
|
||||
c.close();
|
||||
|
||||
return details;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.limelight.computers;
|
||||
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
|
||||
public interface ComputerManagerListener {
|
||||
void notifyComputerUpdated(ComputerDetails details);
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
package com.limelight.computers;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.StringReader;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.binding.PlatformBinding;
|
||||
import com.limelight.discovery.DiscoveryService;
|
||||
import com.limelight.nvstream.NvConnection;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
import com.limelight.nvstream.http.NvHTTP;
|
||||
import com.limelight.nvstream.http.PairingManager;
|
||||
import com.limelight.nvstream.mdns.MdnsComputer;
|
||||
import com.limelight.nvstream.mdns.MdnsDiscoveryListener;
|
||||
import com.limelight.utils.CacheHelper;
|
||||
import com.limelight.utils.NetHelper;
|
||||
import com.limelight.utils.ServerHelper;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.os.Binder;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
public class ComputerManagerService extends Service {
|
||||
private static final int SERVERINFO_POLLING_PERIOD_MS = 1500;
|
||||
private static final int APPLIST_POLLING_PERIOD_MS = 30000;
|
||||
private static final int APPLIST_FAILED_POLLING_RETRY_MS = 2000;
|
||||
private static final int MDNS_QUERY_PERIOD_MS = 1000;
|
||||
private static final int FAST_POLL_TIMEOUT = 1000;
|
||||
private static final int OFFLINE_POLL_TRIES = 5;
|
||||
private static final int INITIAL_POLL_TRIES = 2;
|
||||
private static final int EMPTY_LIST_THRESHOLD = 3;
|
||||
private static final int POLL_DATA_TTL_MS = 30000;
|
||||
|
||||
private final ComputerManagerBinder binder = new ComputerManagerBinder();
|
||||
|
||||
private ComputerDatabaseManager dbManager;
|
||||
private final AtomicInteger dbRefCount = new AtomicInteger(0);
|
||||
|
||||
private IdentityManager idManager;
|
||||
private final LinkedList<PollingTuple> pollingTuples = new LinkedList<>();
|
||||
private ComputerManagerListener listener = null;
|
||||
private final AtomicInteger activePolls = new AtomicInteger(0);
|
||||
private boolean pollingActive = false;
|
||||
private final Lock defaultNetworkLock = new ReentrantLock();
|
||||
|
||||
private DiscoveryService.DiscoveryBinder discoveryBinder;
|
||||
private final ServiceConnection discoveryServiceConnection = new ServiceConnection() {
|
||||
public void onServiceConnected(ComponentName className, IBinder binder) {
|
||||
synchronized (discoveryServiceConnection) {
|
||||
DiscoveryService.DiscoveryBinder privateBinder = ((DiscoveryService.DiscoveryBinder)binder);
|
||||
|
||||
// Set us as the event listener
|
||||
privateBinder.setListener(createDiscoveryListener());
|
||||
|
||||
// Signal a possible waiter that we're all setup
|
||||
discoveryBinder = privateBinder;
|
||||
discoveryServiceConnection.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
public void onServiceDisconnected(ComponentName className) {
|
||||
discoveryBinder = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Returns true if the details object was modified
|
||||
private boolean runPoll(ComputerDetails details, boolean newPc, int offlineCount) throws InterruptedException {
|
||||
if (!getLocalDatabaseReference()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final int pollTriesBeforeOffline = details.state == ComputerDetails.State.UNKNOWN ?
|
||||
INITIAL_POLL_TRIES : OFFLINE_POLL_TRIES;
|
||||
|
||||
activePolls.incrementAndGet();
|
||||
|
||||
// Poll the machine
|
||||
try {
|
||||
if (!pollComputer(details)) {
|
||||
if (!newPc && offlineCount < pollTriesBeforeOffline) {
|
||||
// Return without calling the listener
|
||||
releaseLocalDatabaseReference();
|
||||
return false;
|
||||
}
|
||||
|
||||
details.state = ComputerDetails.State.OFFLINE;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
releaseLocalDatabaseReference();
|
||||
throw e;
|
||||
} finally {
|
||||
activePolls.decrementAndGet();
|
||||
}
|
||||
|
||||
// If it's online, update our persistent state
|
||||
if (details.state == ComputerDetails.State.ONLINE) {
|
||||
ComputerDetails existingComputer = dbManager.getComputerByUUID(details.uuid);
|
||||
|
||||
// Check if it's in the database because it could have been
|
||||
// removed after this was issued
|
||||
if (!newPc && existingComputer == null) {
|
||||
// It's gone
|
||||
releaseLocalDatabaseReference();
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we already have an entry for this computer in the DB, we must
|
||||
// combine the existing data with this new data (which may be partially available
|
||||
// due to detecting the PC via mDNS) without the saved external address. If we
|
||||
// write to the DB without doing this first, we can overwrite our existing data.
|
||||
if (existingComputer != null) {
|
||||
existingComputer.update(details);
|
||||
dbManager.updateComputer(existingComputer);
|
||||
}
|
||||
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);
|
||||
if (addr.isSiteLocalAddress()) {
|
||||
populateExternalAddress(details);
|
||||
}
|
||||
}
|
||||
} catch (UnknownHostException ignored) {}
|
||||
|
||||
dbManager.updateComputer(details);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't call the listener if this is a failed lookup of a new PC
|
||||
if ((!newPc || details.state == ComputerDetails.State.ONLINE) && listener != null) {
|
||||
listener.notifyComputerUpdated(details);
|
||||
}
|
||||
|
||||
releaseLocalDatabaseReference();
|
||||
return true;
|
||||
}
|
||||
|
||||
private Thread createPollingThread(final PollingTuple tuple) {
|
||||
Thread t = new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
|
||||
int offlineCount = 0;
|
||||
while (!isInterrupted() && pollingActive && tuple.thread == this) {
|
||||
try {
|
||||
// Only allow one request to the machine at a time
|
||||
synchronized (tuple.networkLock) {
|
||||
// Check if this poll has modified the details
|
||||
if (!runPoll(tuple.computer, false, offlineCount)) {
|
||||
LimeLog.warning(tuple.computer.name + " is offline (try " + offlineCount + ")");
|
||||
offlineCount++;
|
||||
} else {
|
||||
tuple.lastSuccessfulPollMs = SystemClock.elapsedRealtime();
|
||||
offlineCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait until the next polling interval
|
||||
Thread.sleep(SERVERINFO_POLLING_PERIOD_MS);
|
||||
} catch (InterruptedException e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
t.setName("Polling thread for " + tuple.computer.name);
|
||||
return t;
|
||||
}
|
||||
|
||||
public class ComputerManagerBinder extends Binder {
|
||||
public void startPolling(ComputerManagerListener listener) {
|
||||
// Polling is active
|
||||
pollingActive = true;
|
||||
|
||||
// Set the listener
|
||||
ComputerManagerService.this.listener = listener;
|
||||
|
||||
// Start mDNS autodiscovery too
|
||||
discoveryBinder.startDiscovery(MDNS_QUERY_PERIOD_MS);
|
||||
|
||||
synchronized (pollingTuples) {
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
// Enforce the poll data TTL
|
||||
if (SystemClock.elapsedRealtime() - tuple.lastSuccessfulPollMs > POLL_DATA_TTL_MS) {
|
||||
LimeLog.info("Timing out polled state for "+tuple.computer.name);
|
||||
tuple.computer.state = ComputerDetails.State.UNKNOWN;
|
||||
}
|
||||
|
||||
// Report this computer initially
|
||||
listener.notifyComputerUpdated(tuple.computer);
|
||||
|
||||
// This polling thread might already be there
|
||||
if (tuple.thread == null) {
|
||||
tuple.thread = createPollingThread(tuple);
|
||||
tuple.thread.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void waitForReady() {
|
||||
synchronized (discoveryServiceConnection) {
|
||||
try {
|
||||
while (discoveryBinder == null) {
|
||||
// Wait for the bind notification
|
||||
discoveryServiceConnection.wait(1000);
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void waitForPollingStopped() {
|
||||
while (activePolls.get() != 0) {
|
||||
try {
|
||||
Thread.sleep(250);
|
||||
} catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean addComputerBlocking(ComputerDetails fakeDetails) {
|
||||
return ComputerManagerService.this.addComputerBlocking(fakeDetails);
|
||||
}
|
||||
|
||||
public void removeComputer(ComputerDetails computer) {
|
||||
ComputerManagerService.this.removeComputer(computer);
|
||||
}
|
||||
|
||||
public void stopPolling() {
|
||||
// Just call the unbind handler to cleanup
|
||||
ComputerManagerService.this.onUnbind(null);
|
||||
}
|
||||
|
||||
public ApplistPoller createAppListPoller(ComputerDetails computer) {
|
||||
return new ApplistPoller(computer);
|
||||
}
|
||||
|
||||
public String getUniqueId() {
|
||||
return idManager.getUniqueId();
|
||||
}
|
||||
|
||||
public ComputerDetails getComputer(String uuid) {
|
||||
synchronized (pollingTuples) {
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
if (uuid.equals(tuple.computer.uuid)) {
|
||||
return tuple.computer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void invalidateStateForComputer(String uuid) {
|
||||
synchronized (pollingTuples) {
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
if (uuid.equals(tuple.computer.uuid)) {
|
||||
// We need the network lock to prevent a concurrent poll
|
||||
// from wiping this change out
|
||||
synchronized (tuple.networkLock) {
|
||||
tuple.computer.state = ComputerDetails.State.UNKNOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onUnbind(Intent intent) {
|
||||
if (discoveryBinder != null) {
|
||||
// Stop mDNS autodiscovery
|
||||
discoveryBinder.stopDiscovery();
|
||||
}
|
||||
|
||||
// Stop polling
|
||||
pollingActive = false;
|
||||
synchronized (pollingTuples) {
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
if (tuple.thread != null) {
|
||||
// Interrupt and remove the thread
|
||||
tuple.thread.interrupt();
|
||||
tuple.thread = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the listener
|
||||
listener = null;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void populateExternalAddress(ComputerDetails details) {
|
||||
boolean boundToNetwork = false;
|
||||
boolean activeNetworkIsVpn = NetHelper.isActiveNetworkVpn(this);
|
||||
ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
|
||||
// Check if we're currently connected to a VPN which may send our
|
||||
// STUN request from an unexpected interface
|
||||
if (activeNetworkIsVpn) {
|
||||
// Acquire the default network lock since we could be changing global process state
|
||||
defaultNetworkLock.lock();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
// 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
|
||||
// preferable to getting a VPN endpoint address back).
|
||||
Network[] networks = connMgr.getAllNetworks();
|
||||
for (Network net : networks) {
|
||||
NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(net);
|
||||
if (netCaps != null) {
|
||||
if (!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||
!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
// This network looks like an underlying multicast-capable transport,
|
||||
// so let's guess that it's probably where our mDNS response came from.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (connMgr.bindProcessToNetwork(net)) {
|
||||
boundToNetwork = true;
|
||||
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
|
||||
if (!activeNetworkIsVpn || boundToNetwork) {
|
||||
details.remoteAddress = NvConnection.findExternalAddressForMdns("stun.moonlight-stream.org", 3478);
|
||||
}
|
||||
|
||||
// Unbind from the network
|
||||
if (boundToNetwork) {
|
||||
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
|
||||
if (activeNetworkIsVpn) {
|
||||
defaultNetworkLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private MdnsDiscoveryListener createDiscoveryListener() {
|
||||
return new MdnsDiscoveryListener() {
|
||||
@Override
|
||||
public void notifyComputerAdded(MdnsComputer computer) {
|
||||
ComputerDetails details = new ComputerDetails();
|
||||
|
||||
// Populate the computer template with mDNS info
|
||||
if (computer.getLocalAddress() != null) {
|
||||
details.localAddress = computer.getLocalAddress().getHostAddress();
|
||||
|
||||
// Since we're on the same network, we can use STUN to find
|
||||
// our WAN address, which is also very likely the WAN address
|
||||
// of the PC. We can use this later to connect remotely.
|
||||
if (computer.getLocalAddress() instanceof Inet4Address) {
|
||||
populateExternalAddress(details);
|
||||
}
|
||||
}
|
||||
if (computer.getIpv6Address() != null) {
|
||||
details.ipv6Address = computer.getIpv6Address().getHostAddress();
|
||||
}
|
||||
|
||||
// Kick off a serverinfo poll on this machine
|
||||
if (!addComputerBlocking(details)) {
|
||||
LimeLog.warning("Auto-discovered PC failed to respond: "+details);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyComputerRemoved(MdnsComputer computer) {
|
||||
// Nothing to do here
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyDiscoveryFailure(Exception e) {
|
||||
LimeLog.severe("mDNS discovery failed");
|
||||
e.printStackTrace();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void addTuple(ComputerDetails details) {
|
||||
synchronized (pollingTuples) {
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
// Check if this is the same computer
|
||||
if (tuple.computer.uuid.equals(details.uuid)) {
|
||||
// Update the saved computer with potentially new details
|
||||
tuple.computer.update(details);
|
||||
|
||||
// Start a polling thread if polling is active
|
||||
if (pollingActive && tuple.thread == null) {
|
||||
tuple.thread = createPollingThread(tuple);
|
||||
tuple.thread.start();
|
||||
}
|
||||
|
||||
// Found an entry so we're done
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we got here, we didn't find an entry
|
||||
PollingTuple tuple = new PollingTuple(details, null);
|
||||
if (pollingActive) {
|
||||
tuple.thread = createPollingThread(tuple);
|
||||
}
|
||||
pollingTuples.add(tuple);
|
||||
if (tuple.thread != null) {
|
||||
tuple.thread.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean addComputerBlocking(ComputerDetails fakeDetails) {
|
||||
// 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
|
||||
// in the database, which would be bad because we don't have our pinned cert loaded yet.
|
||||
if (pollComputer(fakeDetails)) {
|
||||
// See if we have record of this PC to pull its pinned cert
|
||||
synchronized (pollingTuples) {
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
if (tuple.computer.uuid.equals(fakeDetails.uuid)) {
|
||||
fakeDetails.serverCert = tuple.computer.serverCert;
|
||||
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;
|
||||
}
|
||||
|
||||
// If the machine is reachable, it was successful
|
||||
if (fakeDetails.state == ComputerDetails.State.ONLINE) {
|
||||
LimeLog.info("New PC ("+fakeDetails.name+") is UUID "+fakeDetails.uuid);
|
||||
|
||||
// Start a polling thread for this machine
|
||||
addTuple(fakeDetails);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void removeComputer(ComputerDetails computer) {
|
||||
if (!getLocalDatabaseReference()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove it from the database
|
||||
dbManager.deleteComputer(computer);
|
||||
|
||||
synchronized (pollingTuples) {
|
||||
// Remove the computer from the computer list
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
if (tuple.computer.uuid.equals(computer.uuid)) {
|
||||
if (tuple.thread != null) {
|
||||
// Interrupt the thread on this entry
|
||||
tuple.thread.interrupt();
|
||||
tuple.thread = null;
|
||||
}
|
||||
pollingTuples.remove(tuple);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
releaseLocalDatabaseReference();
|
||||
}
|
||||
|
||||
private boolean getLocalDatabaseReference() {
|
||||
if (dbRefCount.get() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dbRefCount.incrementAndGet();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void releaseLocalDatabaseReference() {
|
||||
if (dbRefCount.decrementAndGet() == 0) {
|
||||
dbManager.close();
|
||||
}
|
||||
}
|
||||
|
||||
private ComputerDetails tryPollIp(ComputerDetails details, String address) {
|
||||
// Fast poll this address first to determine if we can connect at the TCP layer
|
||||
if (!fastPollIp(address)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
NvHTTP http = new NvHTTP(address, idManager.getUniqueId(), details.serverCert,
|
||||
PlatformBinding.getCryptoProvider(ComputerManagerService.this));
|
||||
|
||||
ComputerDetails newDetails = http.getComputerDetails();
|
||||
|
||||
// Check if this is the PC we expected
|
||||
if (newDetails.uuid == null) {
|
||||
LimeLog.severe("Polling returned no UUID!");
|
||||
return null;
|
||||
}
|
||||
// details.uuid can be null on initial PC add
|
||||
else if (details.uuid != null && !details.uuid.equals(newDetails.uuid)) {
|
||||
// We got the wrong PC!
|
||||
LimeLog.info("Polling returned the wrong PC!");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set the new active address
|
||||
newDetails.activeAddress = address;
|
||||
|
||||
return newDetails;
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Just try to establish a TCP connection to speculatively detect a running
|
||||
// GFE server
|
||||
private boolean fastPollIp(String address) {
|
||||
if (address == null) {
|
||||
// Don't bother if our address is null
|
||||
return false;
|
||||
}
|
||||
|
||||
Socket s = new Socket();
|
||||
try {
|
||||
s.connect(new InetSocketAddress(address, NvHTTP.HTTPS_PORT), FAST_POLL_TIMEOUT);
|
||||
s.close();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void startFastPollThread(final String address, final boolean[] info) {
|
||||
Thread t = new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
boolean pollRes = fastPollIp(address);
|
||||
|
||||
synchronized (info) {
|
||||
info[0] = true; // Done
|
||||
info[1] = pollRes; // Polling result
|
||||
|
||||
info.notify();
|
||||
}
|
||||
}
|
||||
};
|
||||
t.setName("Fast Poll - "+address);
|
||||
t.start();
|
||||
}
|
||||
|
||||
private String fastPollPc(final String localAddress, final String remoteAddress, final String manualAddress, final String ipv6Address) throws InterruptedException {
|
||||
final boolean[] remoteInfo = new boolean[2];
|
||||
final boolean[] localInfo = new boolean[2];
|
||||
final boolean[] manualInfo = new boolean[2];
|
||||
final boolean[] ipv6Info = new boolean[2];
|
||||
|
||||
startFastPollThread(localAddress, localInfo);
|
||||
startFastPollThread(remoteAddress, remoteInfo);
|
||||
startFastPollThread(manualAddress, manualInfo);
|
||||
startFastPollThread(ipv6Address, ipv6Info);
|
||||
|
||||
// Check local first
|
||||
synchronized (localInfo) {
|
||||
while (!localInfo[0]) {
|
||||
localInfo.wait(500);
|
||||
}
|
||||
|
||||
if (localInfo[1]) {
|
||||
return localAddress;
|
||||
}
|
||||
}
|
||||
|
||||
// Now manual
|
||||
synchronized (manualInfo) {
|
||||
while (!manualInfo[0]) {
|
||||
manualInfo.wait(500);
|
||||
}
|
||||
|
||||
if (manualInfo[1]) {
|
||||
return manualAddress;
|
||||
}
|
||||
}
|
||||
|
||||
// Now remote IPv4
|
||||
synchronized (remoteInfo) {
|
||||
while (!remoteInfo[0]) {
|
||||
remoteInfo.wait(500);
|
||||
}
|
||||
|
||||
if (remoteInfo[1]) {
|
||||
return remoteAddress;
|
||||
}
|
||||
}
|
||||
|
||||
// Now global IPv6
|
||||
synchronized (ipv6Info) {
|
||||
while (!ipv6Info[0]) {
|
||||
ipv6Info.wait(500);
|
||||
}
|
||||
|
||||
if (ipv6Info[1]) {
|
||||
return ipv6Address;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean pollComputer(ComputerDetails details) throws InterruptedException {
|
||||
ComputerDetails polledDetails;
|
||||
|
||||
// Do a TCP-level connection to the HTTP server to see if it's listening.
|
||||
// Do not write this address to details.activeAddress because:
|
||||
// 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) {
|
||||
details.update(polledDetails);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
// Bind to the discovery service
|
||||
bindService(new Intent(this, DiscoveryService.class),
|
||||
discoveryServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
|
||||
// Lookup or generate this device's UID
|
||||
idManager = new IdentityManager(this);
|
||||
|
||||
// Initialize the DB
|
||||
dbManager = new ComputerDatabaseManager(this);
|
||||
dbRefCount.set(1);
|
||||
|
||||
// Grab known machines into our computer list
|
||||
if (!getLocalDatabaseReference()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (ComputerDetails computer : dbManager.getAllComputers()) {
|
||||
// Add tuples for each computer
|
||||
addTuple(computer);
|
||||
}
|
||||
|
||||
releaseLocalDatabaseReference();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (discoveryBinder != null) {
|
||||
// Unbind from the discovery service
|
||||
unbindService(discoveryServiceConnection);
|
||||
}
|
||||
|
||||
// FIXME: Should await termination here but we have timeout issues in HttpURLConnection
|
||||
|
||||
// Remove the initial DB reference
|
||||
releaseLocalDatabaseReference();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
|
||||
public class ApplistPoller {
|
||||
private Thread thread;
|
||||
private final ComputerDetails computer;
|
||||
private final Object pollEvent = new Object();
|
||||
private boolean receivedAppList = false;
|
||||
|
||||
public ApplistPoller(ComputerDetails computer) {
|
||||
this.computer = computer;
|
||||
}
|
||||
|
||||
public void pollNow() {
|
||||
synchronized (pollEvent) {
|
||||
pollEvent.notify();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean waitPollingDelay() {
|
||||
try {
|
||||
synchronized (pollEvent) {
|
||||
if (receivedAppList) {
|
||||
// If we've already reported an app list successfully,
|
||||
// wait the full polling period
|
||||
pollEvent.wait(APPLIST_POLLING_PERIOD_MS);
|
||||
}
|
||||
else {
|
||||
// If we've failed to get an app list so far, retry much earlier
|
||||
pollEvent.wait(APPLIST_FAILED_POLLING_RETRY_MS);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return thread != null && !thread.isInterrupted();
|
||||
}
|
||||
|
||||
private PollingTuple getPollingTuple(ComputerDetails details) {
|
||||
synchronized (pollingTuples) {
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
if (details.uuid.equals(tuple.computer.uuid)) {
|
||||
return tuple;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
thread = new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
int emptyAppListResponses = 0;
|
||||
do {
|
||||
// Can't poll if it's not online or paired
|
||||
if (computer.state != ComputerDetails.State.ONLINE ||
|
||||
computer.pairState != PairingManager.PairState.PAIRED) {
|
||||
if (listener != null) {
|
||||
listener.notifyComputerUpdated(computer);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Can't poll if there's no UUID yet
|
||||
if (computer.uuid == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PollingTuple tuple = getPollingTuple(computer);
|
||||
|
||||
try {
|
||||
NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), idManager.getUniqueId(),
|
||||
computer.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this));
|
||||
|
||||
String appList;
|
||||
if (tuple != null) {
|
||||
// If we're polling this machine too, grab the network lock
|
||||
// while doing the app list request to prevent other requests
|
||||
// from being issued in the meantime.
|
||||
synchronized (tuple.networkLock) {
|
||||
appList = http.getAppListRaw();
|
||||
}
|
||||
}
|
||||
else {
|
||||
// No polling is happening now, so we just call it directly
|
||||
appList = http.getAppListRaw();
|
||||
}
|
||||
|
||||
List<NvApp> list = NvHTTP.getAppListByReader(new StringReader(appList));
|
||||
if (list.isEmpty()) {
|
||||
LimeLog.warning("Empty app list received from "+computer.uuid);
|
||||
|
||||
// The app list might actually be empty, so if we get an empty response a few times
|
||||
// in a row, we'll go ahead and believe it.
|
||||
emptyAppListResponses++;
|
||||
}
|
||||
if (!appList.isEmpty() &&
|
||||
(!list.isEmpty() || emptyAppListResponses >= EMPTY_LIST_THRESHOLD)) {
|
||||
// Open the cache file
|
||||
OutputStream cacheOut = null;
|
||||
try {
|
||||
cacheOut = CacheHelper.openCacheFileForOutput(getCacheDir(), "applist", computer.uuid);
|
||||
CacheHelper.writeStringToOutputStream(cacheOut, appList);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
try {
|
||||
if (cacheOut != null) {
|
||||
cacheOut.close();
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
// Reset empty count if it wasn't empty this time
|
||||
if (!list.isEmpty()) {
|
||||
emptyAppListResponses = 0;
|
||||
}
|
||||
|
||||
// Update the computer
|
||||
computer.rawAppList = appList;
|
||||
receivedAppList = true;
|
||||
|
||||
// Notify that the app list has been updated
|
||||
// and ensure that the thread is still active
|
||||
if (listener != null && thread != null) {
|
||||
listener.notifyComputerUpdated(computer);
|
||||
}
|
||||
}
|
||||
else if (appList.isEmpty()) {
|
||||
LimeLog.warning("Null app list received from "+computer.uuid);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
} catch (XmlPullParserException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
} while (waitPollingDelay());
|
||||
}
|
||||
};
|
||||
thread.setName("App list polling thread for " + computer.name);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (thread != null) {
|
||||
thread.interrupt();
|
||||
|
||||
// Don't join here because we might be blocked on network I/O
|
||||
|
||||
thread = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PollingTuple {
|
||||
public Thread thread;
|
||||
public final ComputerDetails computer;
|
||||
public final Object networkLock;
|
||||
public long lastSuccessfulPollMs;
|
||||
|
||||
public PollingTuple(ComputerDetails computer, Thread thread) {
|
||||
this.computer = computer;
|
||||
this.thread = thread;
|
||||
this.networkLock = new Object();
|
||||
}
|
||||
}
|
||||
|
||||
class ReachabilityTuple {
|
||||
public final String reachableAddress;
|
||||
public final ComputerDetails computer;
|
||||
|
||||
public ReachabilityTuple(ComputerDetails computer, String reachableAddress) {
|
||||
this.computer = computer;
|
||||
this.reachableAddress = reachableAddress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.limelight.computers;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.util.Locale;
|
||||
import java.util.Random;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
public class IdentityManager {
|
||||
private static final String UNIQUE_ID_FILE_NAME = "uniqueid";
|
||||
private static final int UID_SIZE_IN_BYTES = 8;
|
||||
|
||||
private String uniqueId;
|
||||
|
||||
public IdentityManager(Context c) {
|
||||
uniqueId = loadUniqueId(c);
|
||||
if (uniqueId == null) {
|
||||
uniqueId = generateNewUniqueId(c);
|
||||
}
|
||||
|
||||
LimeLog.info("UID is now: "+uniqueId);
|
||||
}
|
||||
|
||||
public String getUniqueId() {
|
||||
return uniqueId;
|
||||
}
|
||||
|
||||
private static String loadUniqueId(Context c) {
|
||||
// 2 Hex digits per byte
|
||||
char[] uid = new char[UID_SIZE_IN_BYTES * 2];
|
||||
InputStreamReader reader = null;
|
||||
LimeLog.info("Reading UID from disk");
|
||||
try {
|
||||
reader = new InputStreamReader(c.openFileInput(UNIQUE_ID_FILE_NAME));
|
||||
if (reader.read(uid) != UID_SIZE_IN_BYTES * 2)
|
||||
{
|
||||
LimeLog.severe("UID file data is truncated");
|
||||
return null;
|
||||
}
|
||||
return new String(uid);
|
||||
} catch (FileNotFoundException e) {
|
||||
LimeLog.info("No UID file found");
|
||||
return null;
|
||||
} catch (IOException e) {
|
||||
LimeLog.severe("Error while reading UID file");
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
} finally {
|
||||
if (reader != null) {
|
||||
try {
|
||||
reader.close();
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String generateNewUniqueId(Context c) {
|
||||
// Generate a new UID hex string
|
||||
LimeLog.info("Generating new UID");
|
||||
String uidStr = String.format((Locale)null, "%016x", new Random().nextLong());
|
||||
|
||||
OutputStreamWriter writer = null;
|
||||
try {
|
||||
writer = new OutputStreamWriter(c.openFileOutput(UNIQUE_ID_FILE_NAME, 0));
|
||||
writer.write(uidStr);
|
||||
LimeLog.info("UID written to disk");
|
||||
} catch (IOException e) {
|
||||
LimeLog.severe("Error while writing UID file");
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
if (writer != null) {
|
||||
try {
|
||||
writer.close();
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
// We can return a UID even if I/O fails
|
||||
return uidStr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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.LimeLog;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class LegacyDatabaseReader {
|
||||
private static final String COMPUTER_DB_NAME = "computers.db";
|
||||
private static final String COMPUTER_TABLE_NAME = "Computers";
|
||||
|
||||
private static final String ADDRESS_PREFIX = "ADDRESS_PREFIX__";
|
||||
|
||||
private static ComputerDetails getComputerFromCursor(Cursor c) {
|
||||
ComputerDetails details = new ComputerDetails();
|
||||
|
||||
details.name = c.getString(0);
|
||||
details.uuid = c.getString(1);
|
||||
|
||||
// An earlier schema defined addresses as byte blobs. We'll
|
||||
// gracefully migrate those to strings so we can store DNS names
|
||||
// too. To disambiguate, we'll need to prefix them with a string
|
||||
// greater than the allowable IP address length.
|
||||
try {
|
||||
details.localAddress = InetAddress.getByAddress(c.getBlob(2)).getHostAddress();
|
||||
LimeLog.warning("DB: Legacy local address for " + details.name);
|
||||
} catch (UnknownHostException e) {
|
||||
// This is probably a hostname/address with the prefix string
|
||||
String stringData = c.getString(2);
|
||||
if (stringData.startsWith(ADDRESS_PREFIX)) {
|
||||
details.localAddress = c.getString(2).substring(ADDRESS_PREFIX.length());
|
||||
} else {
|
||||
LimeLog.severe("DB: Corrupted local address for " + details.name);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
details.remoteAddress = InetAddress.getByAddress(c.getBlob(3)).getHostAddress();
|
||||
LimeLog.warning("DB: Legacy remote address for " + details.name);
|
||||
} catch (UnknownHostException e) {
|
||||
// This is probably a hostname/address with the prefix string
|
||||
String stringData = c.getString(3);
|
||||
if (stringData.startsWith(ADDRESS_PREFIX)) {
|
||||
details.remoteAddress = c.getString(3).substring(ADDRESS_PREFIX.length());
|
||||
} else {
|
||||
LimeLog.severe("DB: Corrupted remote address for " + details.name);
|
||||
}
|
||||
}
|
||||
|
||||
// On older versions of Moonlight, this is typically where manual addresses got stored,
|
||||
// so let's initialize it just to be safe.
|
||||
details.manualAddress = details.remoteAddress;
|
||||
|
||||
details.macAddress = c.getString(4);
|
||||
|
||||
// This signifies we don't have dynamic state (like pair state)
|
||||
details.state = ComputerDetails.State.UNKNOWN;
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
private static List<ComputerDetails> getAllComputers(SQLiteDatabase db) {
|
||||
Cursor c = db.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);
|
||||
}
|
||||
|
||||
c.close();
|
||||
|
||||
return computerList;
|
||||
}
|
||||
|
||||
public static List<ComputerDetails> migrateAllComputers(Context c) {
|
||||
SQLiteDatabase computerDb = null;
|
||||
try {
|
||||
// Open the existing database
|
||||
computerDb = SQLiteDatabase.openDatabase(c.getDatabasePath(COMPUTER_DB_NAME).getPath(), null, SQLiteDatabase.OPEN_READONLY);
|
||||
return getAllComputers(computerDb);
|
||||
} catch (SQLiteException e) {
|
||||
return new LinkedList<ComputerDetails>();
|
||||
} finally {
|
||||
// Close and delete the old DB
|
||||
if (computerDb != null) {
|
||||
computerDb.close();
|
||||
}
|
||||
c.deleteDatabase(COMPUTER_DB_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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 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 LegacyDatabaseReader2 {
|
||||
private static final String COMPUTER_DB_NAME = "computers2.db";
|
||||
private static final String COMPUTER_TABLE_NAME = "Computers";
|
||||
|
||||
private static ComputerDetails getComputerFromCursor(Cursor c) {
|
||||
ComputerDetails details = new ComputerDetails();
|
||||
|
||||
details.uuid = c.getString(0);
|
||||
details.name = c.getString(1);
|
||||
details.localAddress = c.getString(2);
|
||||
details.remoteAddress = c.getString(3);
|
||||
details.manualAddress = c.getString(4);
|
||||
details.macAddress = c.getString(5);
|
||||
|
||||
// This column wasn't always present in the old schema
|
||||
if (c.getColumnCount() >= 7) {
|
||||
try {
|
||||
byte[] derCertData = c.getBlob(6);
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
c.close();
|
||||
|
||||
return computerList;
|
||||
}
|
||||
|
||||
public static List<ComputerDetails> migrateAllComputers(Context c) {
|
||||
SQLiteDatabase computerDb = null;
|
||||
try {
|
||||
// Open the existing database
|
||||
computerDb = SQLiteDatabase.openDatabase(c.getDatabasePath(COMPUTER_DB_NAME).getPath(), null, SQLiteDatabase.OPEN_READONLY);
|
||||
return getAllComputers(computerDb);
|
||||
} catch (SQLiteException e) {
|
||||
return new LinkedList<ComputerDetails>();
|
||||
} finally {
|
||||
// Close and delete the old DB
|
||||
if (computerDb != null) {
|
||||
computerDb.close();
|
||||
}
|
||||
c.deleteDatabase(COMPUTER_DB_NAME);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.limelight.discovery;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.limelight.nvstream.mdns.MdnsComputer;
|
||||
import com.limelight.nvstream.mdns.MdnsDiscoveryAgent;
|
||||
import com.limelight.nvstream.mdns.MdnsDiscoveryListener;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.net.wifi.WifiManager.MulticastLock;
|
||||
import android.os.Binder;
|
||||
import android.os.IBinder;
|
||||
|
||||
public class DiscoveryService extends Service {
|
||||
|
||||
private MdnsDiscoveryAgent discoveryAgent;
|
||||
private MdnsDiscoveryListener boundListener;
|
||||
private MulticastLock multicastLock;
|
||||
|
||||
public class DiscoveryBinder extends Binder {
|
||||
public void setListener(MdnsDiscoveryListener listener) {
|
||||
boundListener = listener;
|
||||
}
|
||||
|
||||
public void startDiscovery(int queryIntervalMs) {
|
||||
multicastLock.acquire();
|
||||
discoveryAgent.startDiscovery(queryIntervalMs);
|
||||
}
|
||||
|
||||
public void stopDiscovery() {
|
||||
discoveryAgent.stopDiscovery();
|
||||
multicastLock.release();
|
||||
}
|
||||
|
||||
public List<MdnsComputer> getComputerSet() {
|
||||
return discoveryAgent.getComputerSet();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
WifiManager wifiMgr = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);
|
||||
multicastLock = wifiMgr.createMulticastLock("Limelight mDNS");
|
||||
multicastLock.setReferenceCounted(false);
|
||||
|
||||
discoveryAgent = new MdnsDiscoveryAgent(new MdnsDiscoveryListener() {
|
||||
@Override
|
||||
public void notifyComputerAdded(MdnsComputer computer) {
|
||||
if (boundListener != null) {
|
||||
boundListener.notifyComputerAdded(computer);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyComputerRemoved(MdnsComputer computer) {
|
||||
if (boundListener != null) {
|
||||
boundListener.notifyComputerRemoved(computer);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyDiscoveryFailure(Exception e) {
|
||||
if (boundListener != null) {
|
||||
boundListener.notifyDiscoveryFailure(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private final DiscoveryBinder binder = new DiscoveryBinder();
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onUnbind(Intent intent) {
|
||||
// Stop any discovery session
|
||||
discoveryAgent.stopDiscovery();
|
||||
multicastLock.release();
|
||||
|
||||
// Unbind the listener
|
||||
boundListener = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.limelight.grid;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.limelight.AppView;
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.R;
|
||||
import com.limelight.grid.assets.CachedAppAssetLoader;
|
||||
import com.limelight.grid.assets.DiskAssetLoader;
|
||||
import com.limelight.grid.assets.MemoryAssetLoader;
|
||||
import com.limelight.grid.assets.NetworkAssetLoader;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.preferences.PreferenceConfiguration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
||||
private static final int ART_WIDTH_PX = 300;
|
||||
private static final int SMALL_WIDTH_DP = 100;
|
||||
private static final int LARGE_WIDTH_DP = 150;
|
||||
|
||||
private final ComputerDetails computer;
|
||||
private final String uniqueId;
|
||||
private final boolean showHiddenApps;
|
||||
|
||||
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, boolean showHiddenApps) {
|
||||
super(context, getLayoutIdForPreferences(prefs));
|
||||
|
||||
this.computer = computer;
|
||||
this.uniqueId = uniqueId;
|
||||
this.showHiddenApps = showHiddenApps;
|
||||
|
||||
updateLayoutWithPreferences(context, prefs);
|
||||
}
|
||||
|
||||
public void updateHiddenApps(Set<Integer> newHiddenAppIds, boolean hideImmediately) {
|
||||
this.hiddenAppIds.clear();
|
||||
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 {
|
||||
// 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;
|
||||
}
|
||||
else {
|
||||
return R.layout.app_grid_item;
|
||||
}
|
||||
}
|
||||
|
||||
public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) {
|
||||
int dpi = context.getResources().getDisplayMetrics().densityDpi;
|
||||
int dp;
|
||||
|
||||
if (prefs.smallIconMode) {
|
||||
dp = SMALL_WIDTH_DP;
|
||||
}
|
||||
else {
|
||||
dp = LARGE_WIDTH_DP;
|
||||
}
|
||||
|
||||
double scalingDivisor = ART_WIDTH_PX / (dp * (dpi / 160.0));
|
||||
if (scalingDivisor < 1.0) {
|
||||
// We don't want to make them bigger before draw-time
|
||||
scalingDivisor = 1.0;
|
||||
}
|
||||
LimeLog.info("Art scaling divisor: " + scalingDivisor);
|
||||
|
||||
if (loader != null) {
|
||||
// Cancel operations on the old loader
|
||||
cancelQueuedOperations();
|
||||
}
|
||||
|
||||
this.loader = new CachedAppAssetLoader(computer, scalingDivisor,
|
||||
new NetworkAssetLoader(context, uniqueId),
|
||||
new MemoryAssetLoader(),
|
||||
new DiskAssetLoader(context),
|
||||
BitmapFactory.decodeResource(context.getResources(), R.drawable.no_app_image));
|
||||
|
||||
// This will trigger the view to reload with the new layout
|
||||
setLayoutId(getLayoutIdForPreferences(prefs));
|
||||
}
|
||||
|
||||
public void cancelQueuedOperations() {
|
||||
loader.cancelForegroundLoads();
|
||||
loader.cancelBackgroundLoads();
|
||||
loader.freeCacheMemory();
|
||||
}
|
||||
|
||||
private static void sortList(List<AppView.AppObject> list) {
|
||||
Collections.sort(list, new Comparator<AppView.AppObject>() {
|
||||
@Override
|
||||
public int compare(AppView.AppObject lhs, AppView.AppObject rhs) {
|
||||
return lhs.app.getAppName().toLowerCase().compareTo(rhs.app.getAppName().toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void addApp(AppView.AppObject app) {
|
||||
// Update hidden state
|
||||
app.isHidden = hiddenAppIds.contains(app.app.getAppId());
|
||||
|
||||
// Always add the app to the all apps list
|
||||
allApps.add(app);
|
||||
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) {
|
||||
itemList.remove(app);
|
||||
allApps.remove(app);
|
||||
}
|
||||
|
||||
@Override
|
||||
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
|
||||
loader.populateImageView(obj.app, imgView, txtView);
|
||||
|
||||
if (obj.isRunning) {
|
||||
// Show the play button overlay
|
||||
overlayView.setImageResource(R.drawable.ic_play);
|
||||
overlayView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
else {
|
||||
overlayView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (obj.isHidden) {
|
||||
parentView.setAlpha(0.40f);
|
||||
}
|
||||
else {
|
||||
parentView.setAlpha(1.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.limelight.grid;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.BaseAdapter;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.limelight.R;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public abstract class GenericGridAdapter<T> extends BaseAdapter {
|
||||
protected final Context context;
|
||||
private int layoutId;
|
||||
final ArrayList<T> itemList = new ArrayList<>();
|
||||
private final LayoutInflater inflater;
|
||||
|
||||
GenericGridAdapter(Context context, int layoutId) {
|
||||
this.context = context;
|
||||
this.layoutId = layoutId;
|
||||
|
||||
this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
}
|
||||
|
||||
void setLayoutId(int layoutId) {
|
||||
if (layoutId != this.layoutId) {
|
||||
this.layoutId = layoutId;
|
||||
|
||||
// Force the view to be redrawn with the new layout
|
||||
notifyDataSetInvalidated();
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
itemList.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return itemList.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getItem(int i) {
|
||||
return itemList.get(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int i) {
|
||||
return i;
|
||||
}
|
||||
|
||||
public abstract void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, T obj);
|
||||
|
||||
@Override
|
||||
public View getView(int i, View convertView, ViewGroup viewGroup) {
|
||||
if (convertView == null) {
|
||||
convertView = inflater.inflate(layoutId, viewGroup, false);
|
||||
}
|
||||
|
||||
ImageView imgView = convertView.findViewById(R.id.grid_image);
|
||||
ImageView overlayView = convertView.findViewById(R.id.grid_overlay);
|
||||
TextView txtView = convertView.findViewById(R.id.grid_text);
|
||||
ProgressBar prgView = convertView.findViewById(R.id.grid_spinner);
|
||||
|
||||
populateView(convertView, imgView, prgView, txtView, overlayView, itemList.get(i));
|
||||
|
||||
return convertView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.limelight.grid;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.limelight.PcView;
|
||||
import com.limelight.R;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.PairingManager;
|
||||
import com.limelight.preferences.PreferenceConfiguration;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
|
||||
public class PcGridAdapter extends GenericGridAdapter<PcView.ComputerObject> {
|
||||
|
||||
public PcGridAdapter(Context context, PreferenceConfiguration prefs) {
|
||||
super(context, getLayoutIdForPreferences(prefs));
|
||||
}
|
||||
|
||||
private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) {
|
||||
return R.layout.pc_grid_item;
|
||||
}
|
||||
|
||||
public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) {
|
||||
// This will trigger the view to reload with the new layout
|
||||
setLayoutId(getLayoutIdForPreferences(prefs));
|
||||
}
|
||||
|
||||
public void addComputer(PcView.ComputerObject computer) {
|
||||
itemList.add(computer);
|
||||
sortList();
|
||||
}
|
||||
|
||||
private void sortList() {
|
||||
Collections.sort(itemList, new Comparator<PcView.ComputerObject>() {
|
||||
@Override
|
||||
public int compare(PcView.ComputerObject lhs, PcView.ComputerObject rhs) {
|
||||
return lhs.details.name.toLowerCase().compareTo(rhs.details.name.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public boolean removeComputer(PcView.ComputerObject computer) {
|
||||
return itemList.remove(computer);
|
||||
}
|
||||
|
||||
@Override
|
||||
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) {
|
||||
imgView.setAlpha(1.0f);
|
||||
}
|
||||
else {
|
||||
imgView.setAlpha(0.4f);
|
||||
}
|
||||
|
||||
if (obj.details.state == ComputerDetails.State.UNKNOWN) {
|
||||
prgView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
else {
|
||||
prgView.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
txtView.setText(obj.details.name);
|
||||
if (obj.details.state == ComputerDetails.State.ONLINE) {
|
||||
txtView.setAlpha(1.0f);
|
||||
}
|
||||
else {
|
||||
txtView.setAlpha(0.4f);
|
||||
}
|
||||
|
||||
if (obj.details.state == ComputerDetails.State.OFFLINE) {
|
||||
overlayView.setImageResource(R.drawable.ic_pc_offline);
|
||||
overlayView.setAlpha(0.4f);
|
||||
overlayView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
// We must check if the status is exactly online and unpaired
|
||||
// to avoid colliding with the loading spinner when status is unknown
|
||||
else if (obj.details.state == ComputerDetails.State.ONLINE &&
|
||||
obj.details.pairState == PairingManager.PairState.NOT_PAIRED) {
|
||||
overlayView.setImageResource(R.drawable.ic_lock);
|
||||
overlayView.setAlpha(1.0f);
|
||||
overlayView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
else {
|
||||
overlayView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package com.limelight.grid.assets;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.AsyncTask;
|
||||
import android.view.View;
|
||||
import android.view.animation.Animation;
|
||||
import android.view.animation.AnimationUtils;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.limelight.R;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class CachedAppAssetLoader {
|
||||
private static final int MAX_CONCURRENT_DISK_LOADS = 3;
|
||||
private static final int MAX_CONCURRENT_NETWORK_LOADS = 3;
|
||||
private static final int MAX_CONCURRENT_CACHE_LOADS = 1;
|
||||
|
||||
private static final int MAX_PENDING_CACHE_LOADS = 100;
|
||||
private static final int MAX_PENDING_NETWORK_LOADS = 40;
|
||||
private static final int MAX_PENDING_DISK_LOADS = 40;
|
||||
|
||||
private final ThreadPoolExecutor cacheExecutor = new ThreadPoolExecutor(
|
||||
MAX_CONCURRENT_CACHE_LOADS, MAX_CONCURRENT_CACHE_LOADS,
|
||||
Long.MAX_VALUE, TimeUnit.DAYS,
|
||||
new LinkedBlockingQueue<Runnable>(MAX_PENDING_CACHE_LOADS),
|
||||
new ThreadPoolExecutor.DiscardOldestPolicy());
|
||||
|
||||
private final ThreadPoolExecutor foregroundExecutor = new ThreadPoolExecutor(
|
||||
MAX_CONCURRENT_DISK_LOADS, MAX_CONCURRENT_DISK_LOADS,
|
||||
Long.MAX_VALUE, TimeUnit.DAYS,
|
||||
new LinkedBlockingQueue<Runnable>(MAX_PENDING_DISK_LOADS),
|
||||
new ThreadPoolExecutor.DiscardOldestPolicy());
|
||||
|
||||
private final ThreadPoolExecutor networkExecutor = new ThreadPoolExecutor(
|
||||
MAX_CONCURRENT_NETWORK_LOADS, MAX_CONCURRENT_NETWORK_LOADS,
|
||||
Long.MAX_VALUE, TimeUnit.DAYS,
|
||||
new LinkedBlockingQueue<Runnable>(MAX_PENDING_NETWORK_LOADS),
|
||||
new ThreadPoolExecutor.DiscardOldestPolicy());
|
||||
|
||||
private final ComputerDetails computer;
|
||||
private final double scalingDivider;
|
||||
private final NetworkAssetLoader networkLoader;
|
||||
private final MemoryAssetLoader memoryLoader;
|
||||
private final DiskAssetLoader diskLoader;
|
||||
private final Bitmap placeholderBitmap;
|
||||
private final Bitmap noAppImageBitmap;
|
||||
|
||||
public CachedAppAssetLoader(ComputerDetails computer, double scalingDivider,
|
||||
NetworkAssetLoader networkLoader, MemoryAssetLoader memoryLoader,
|
||||
DiskAssetLoader diskLoader, Bitmap noAppImageBitmap) {
|
||||
this.computer = computer;
|
||||
this.scalingDivider = scalingDivider;
|
||||
this.networkLoader = networkLoader;
|
||||
this.memoryLoader = memoryLoader;
|
||||
this.diskLoader = diskLoader;
|
||||
this.noAppImageBitmap = noAppImageBitmap;
|
||||
this.placeholderBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
|
||||
}
|
||||
|
||||
public void cancelBackgroundLoads() {
|
||||
Runnable r;
|
||||
while ((r = cacheExecutor.getQueue().poll()) != null) {
|
||||
cacheExecutor.remove(r);
|
||||
}
|
||||
}
|
||||
|
||||
public void cancelForegroundLoads() {
|
||||
Runnable r;
|
||||
|
||||
while ((r = foregroundExecutor.getQueue().poll()) != null) {
|
||||
foregroundExecutor.remove(r);
|
||||
}
|
||||
|
||||
while ((r = networkExecutor.getQueue().poll()) != null) {
|
||||
networkExecutor.remove(r);
|
||||
}
|
||||
}
|
||||
|
||||
public void freeCacheMemory() {
|
||||
memoryLoader.clearCache();
|
||||
}
|
||||
|
||||
private ScaledBitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) {
|
||||
// Try 3 times
|
||||
for (int i = 0; i < 3; i++) {
|
||||
// Check again whether we've been cancelled or the image view is gone
|
||||
if (task != null && (task.isCancelled() || task.imageViewRef.get() == null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
InputStream in = networkLoader.getBitmapStream(tuple);
|
||||
if (in != null) {
|
||||
// Write the stream straight to disk
|
||||
diskLoader.populateCacheWithStream(tuple, in);
|
||||
|
||||
// Close the network input stream
|
||||
try {
|
||||
in.close();
|
||||
} catch (IOException ignored) {}
|
||||
|
||||
// If there's a task associated with this load, we should return the bitmap
|
||||
if (task != null) {
|
||||
// If the cached bitmap is valid, return it. Otherwise, we'll try the load again
|
||||
ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
||||
if (bmp != null) {
|
||||
return bmp;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Otherwise it's a background load and we return nothing
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait 1 second with a bit of fuzz
|
||||
try {
|
||||
Thread.sleep((int) (1000 + (Math.random() * 500)));
|
||||
} catch (InterruptedException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private class LoaderTask extends AsyncTask<LoaderTuple, Void, ScaledBitmap> {
|
||||
private final WeakReference<ImageView> imageViewRef;
|
||||
private final WeakReference<TextView> textViewRef;
|
||||
private final boolean diskOnly;
|
||||
|
||||
private LoaderTuple tuple;
|
||||
|
||||
public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly) {
|
||||
this.imageViewRef = new WeakReference<>(imageView);
|
||||
this.textViewRef = new WeakReference<>(textView);
|
||||
this.diskOnly = diskOnly;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ScaledBitmap doInBackground(LoaderTuple... params) {
|
||||
tuple = params[0];
|
||||
|
||||
// Check whether it has been cancelled or the views are gone
|
||||
if (isCancelled() || imageViewRef.get() == null || textViewRef.get() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
||||
if (bmp == null) {
|
||||
if (!diskOnly) {
|
||||
// Try to load the asset from the network
|
||||
bmp = doNetworkAssetLoad(tuple, this);
|
||||
} else {
|
||||
// Report progress to display the placeholder and spin
|
||||
// off the network-capable task
|
||||
publishProgress();
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the bitmap
|
||||
if (bmp != null) {
|
||||
memoryLoader.populateCache(tuple, bmp);
|
||||
}
|
||||
|
||||
return bmp;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onProgressUpdate(Void... nothing) {
|
||||
// Do nothing if cancelled
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the current loader task for this view isn't us, do nothing
|
||||
final ImageView imageView = imageViewRef.get();
|
||||
final TextView textView = textViewRef.get();
|
||||
if (getLoaderTask(imageView) == this) {
|
||||
// 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.
|
||||
LoaderTask task = new LoaderTask(imageView, textView, false);
|
||||
AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getResources(), noAppImageBitmap, task);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(final ScaledBitmap bitmap) {
|
||||
// Do nothing if cancelled
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ImageView imageView = imageViewRef.get();
|
||||
final TextView textView = textViewRef.get();
|
||||
if (getLoaderTask(imageView) == this) {
|
||||
// Fade in the box art
|
||||
if (bitmap != null) {
|
||||
// Show the text if it's a placeholder
|
||||
textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (imageView.getVisibility() == View.VISIBLE) {
|
||||
// Fade out the placeholder first
|
||||
Animation fadeOutAnimation = AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadeout);
|
||||
fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() {
|
||||
@Override
|
||||
public void onAnimationStart(Animation animation) {}
|
||||
|
||||
@Override
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class AsyncDrawable extends BitmapDrawable {
|
||||
private final WeakReference<LoaderTask> loaderTaskReference;
|
||||
|
||||
public AsyncDrawable(Resources res, Bitmap bitmap,
|
||||
LoaderTask loaderTask) {
|
||||
super(res, bitmap);
|
||||
loaderTaskReference = new WeakReference<>(loaderTask);
|
||||
}
|
||||
|
||||
public LoaderTask getLoaderTask() {
|
||||
return loaderTaskReference.get();
|
||||
}
|
||||
}
|
||||
|
||||
private static LoaderTask getLoaderTask(ImageView imageView) {
|
||||
if (imageView == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Drawable drawable = imageView.getDrawable();
|
||||
|
||||
// If our drawable is in play, get the loader task
|
||||
if (drawable instanceof AsyncDrawable) {
|
||||
final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
|
||||
return asyncDrawable.getLoaderTask();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean cancelPendingLoad(LoaderTuple tuple, ImageView imageView) {
|
||||
final LoaderTask loaderTask = getLoaderTask(imageView);
|
||||
|
||||
// Check if any task was pending for this image view
|
||||
if (loaderTask != null && !loaderTask.isCancelled()) {
|
||||
final LoaderTuple taskTuple = loaderTask.tuple;
|
||||
|
||||
// Cancel the task if it's not already loading the same data
|
||||
if (taskTuple == null || !taskTuple.equals(tuple)) {
|
||||
loaderTask.cancel(true);
|
||||
} else {
|
||||
// It's already loading what we want
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow the load to proceed
|
||||
return true;
|
||||
}
|
||||
|
||||
public void queueCacheLoad(NvApp app) {
|
||||
final LoaderTuple tuple = new LoaderTuple(computer, app);
|
||||
|
||||
if (memoryLoader.loadBitmapFromCache(tuple) != null) {
|
||||
// It's in memory which means it must also be on disk
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue a fetch in the cache executor
|
||||
cacheExecutor.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Check if the image is cached on disk
|
||||
if (diskLoader.checkCacheExists(tuple)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load the asset from the network and cache result on disk
|
||||
doNetworkAssetLoad(tuple, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// If there's already a task in progress for this view,
|
||||
// cancel it. If the task is already loading the same image,
|
||||
// we return and let that load finish.
|
||||
if (!cancelPendingLoad(tuple, imgView)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Always set the name text so we have it if needed later
|
||||
textView.setText(app.getAppName());
|
||||
|
||||
// First, try the memory cache in the current context
|
||||
ScaledBitmap bmp = memoryLoader.loadBitmapFromCache(tuple);
|
||||
if (bmp != null) {
|
||||
// Show the bitmap immediately
|
||||
imgView.setVisibility(View.VISIBLE);
|
||||
imgView.setImageBitmap(bmp.bitmap);
|
||||
|
||||
// Show the text if it's a placeholder bitmap
|
||||
textView.setVisibility(isBitmapPlaceholder(bmp) ? View.VISIBLE : View.GONE);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's not in memory, create an async task to load it. This task will be attached
|
||||
// via AsyncDrawable to this view.
|
||||
final LoaderTask task = new LoaderTask(imgView, textView, true);
|
||||
final AsyncDrawable asyncDrawable = new AsyncDrawable(imgView.getResources(), placeholderBitmap, task);
|
||||
textView.setVisibility(View.INVISIBLE);
|
||||
imgView.setVisibility(View.INVISIBLE);
|
||||
imgView.setImageDrawable(asyncDrawable);
|
||||
|
||||
// Run the task on our foreground executor
|
||||
task.executeOnExecutor(foregroundExecutor, tuple);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static class LoaderTuple {
|
||||
public final ComputerDetails computer;
|
||||
public final NvApp app;
|
||||
|
||||
public LoaderTuple(ComputerDetails computer, NvApp app) {
|
||||
this.computer = computer;
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof LoaderTuple)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LoaderTuple other = (LoaderTuple) o;
|
||||
return computer.uuid.equals(other.computer.uuid) && app.getAppId() == other.app.getAppId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "("+computer.uuid+", "+app.getAppId()+")";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.limelight.grid.assets;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.ImageDecoder;
|
||||
import android.os.Build;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.utils.CacheHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
public class DiskAssetLoader {
|
||||
// 5 MB
|
||||
private static final long MAX_ASSET_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
// Standard box art is 300x400
|
||||
private static final int STANDARD_ASSET_WIDTH = 300;
|
||||
private static final int STANDARD_ASSET_HEIGHT = 400;
|
||||
|
||||
private final boolean isLowRamDevice;
|
||||
private final File cacheDir;
|
||||
|
||||
public DiskAssetLoader(Context context) {
|
||||
this.cacheDir = context.getCacheDir();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
this.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) {
|
||||
return CacheHelper.cacheFileExists(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
|
||||
}
|
||||
|
||||
// https://developer.android.com/topic/performance/graphics/load-bitmap.html
|
||||
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
|
||||
// Raw height and width of image
|
||||
final int height = options.outHeight;
|
||||
final int width = options.outWidth;
|
||||
int inSampleSize = 1;
|
||||
|
||||
if (height > reqHeight || width > reqWidth) {
|
||||
|
||||
final int halfHeight = height / 2;
|
||||
final int halfWidth = width / 2;
|
||||
|
||||
// Calculates the largest inSampleSize value that is a power of 2 and keeps both
|
||||
// height and width larger than the requested height and width.
|
||||
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
|
||||
inSampleSize *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
return inSampleSize;
|
||||
}
|
||||
|
||||
public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, int sampleSize) {
|
||||
File file = getFile(tuple.computer.uuid, tuple.app.getAppId());
|
||||
|
||||
// Don't bother with anything if it doesn't exist
|
||||
if (!file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Make sure the cached asset doesn't exceed the maximum size
|
||||
if (file.length() > MAX_ASSET_SIZE) {
|
||||
LimeLog.warning("Removing cached tuple exceeding size threshold: "+tuple);
|
||||
file.delete();
|
||||
return null;
|
||||
}
|
||||
|
||||
Bitmap bmp;
|
||||
|
||||
// For OSes prior to P, we have to use the ugly BitmapFactory API
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
// Lookup bounds of the downloaded image
|
||||
BitmapFactory.Options decodeOnlyOptions = new BitmapFactory.Options();
|
||||
decodeOnlyOptions.inJustDecodeBounds = true;
|
||||
BitmapFactory.decodeFile(file.getAbsolutePath(), decodeOnlyOptions);
|
||||
if (decodeOnlyOptions.outWidth <= 0 || decodeOnlyOptions.outHeight <= 0) {
|
||||
// Dimensions set to -1 on error. Return value always null.
|
||||
return null;
|
||||
}
|
||||
|
||||
LimeLog.info("Tuple "+tuple+" has cached art of size: "+decodeOnlyOptions.outWidth+"x"+decodeOnlyOptions.outHeight);
|
||||
|
||||
// Load the image scaled to the appropriate size
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inSampleSize = calculateInSampleSize(decodeOnlyOptions,
|
||||
STANDARD_ASSET_WIDTH / sampleSize,
|
||||
STANDARD_ASSET_HEIGHT / sampleSize);
|
||||
if (isLowRamDevice) {
|
||||
options.inPreferredConfig = Bitmap.Config.RGB_565;
|
||||
options.inDither = true;
|
||||
}
|
||||
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
options.inPreferredConfig = Bitmap.Config.HARDWARE;
|
||||
}
|
||||
|
||||
bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
|
||||
if (bmp != null) {
|
||||
LimeLog.info("Tuple "+tuple+" decoded from disk cache with sample size: "+options.inSampleSize);
|
||||
return new ScaledBitmap(decodeOnlyOptions.outWidth, decodeOnlyOptions.outHeight, bmp);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// On P, we can get a bitmap back in one step with ImageDecoder
|
||||
final ScaledBitmap scaledBitmap = new ScaledBitmap();
|
||||
try {
|
||||
scaledBitmap.bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), new ImageDecoder.OnHeaderDecodedListener() {
|
||||
@Override
|
||||
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);
|
||||
if (isLowRamDevice) {
|
||||
imageDecoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM);
|
||||
}
|
||||
}
|
||||
});
|
||||
return scaledBitmap;
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public File getFile(String computerUuid, int appId) {
|
||||
return CacheHelper.openPath(false, cacheDir, "boxart", computerUuid, appId + ".png");
|
||||
}
|
||||
|
||||
public void deleteAssetsForComputer(String computerUuid) {
|
||||
File dir = CacheHelper.openPath(false, cacheDir, "boxart", computerUuid);
|
||||
File[] files = dir.listFiles();
|
||||
if (files != null) {
|
||||
for (File f : files) {
|
||||
f.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void populateCacheWithStream(CachedAppAssetLoader.LoaderTuple tuple, InputStream input) {
|
||||
OutputStream out = null;
|
||||
boolean success = false;
|
||||
try {
|
||||
out = CacheHelper.openCacheFileForOutput(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
|
||||
CacheHelper.writeInputStreamToOutputStream(input, out, MAX_ASSET_SIZE);
|
||||
success = true;
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
if (out != null) {
|
||||
try {
|
||||
out.close();
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
LimeLog.warning("Unable to populate cache with tuple: "+tuple);
|
||||
CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user