mirror of
https://github.com/moonlight-stream/moonlight-android.git
synced 2026-02-16 18:40:46 +00:00
Compare commits
1067 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d3be670974 | ||
|
|
7b41b1158e | ||
|
|
d0da5d3702 | ||
|
|
42668b5699 | ||
|
|
9444430830 | ||
|
|
e30088e53b | ||
|
|
a989bdde80 | ||
|
|
e5d9da447c | ||
|
|
ba5c026bff | ||
|
|
66536aa755 | ||
|
|
a53444148e | ||
|
|
d237ceb1df | ||
|
|
1f504288cb | ||
|
|
0543420624 | ||
|
|
6580eb8ea4 | ||
|
|
8584bf1910 | ||
|
|
e7f92d3667 | ||
|
|
fe71b1be20 | ||
|
|
0ba0d37a37 | ||
|
|
673a115b52 | ||
|
|
bedf472e9e | ||
|
|
f4abc66eeb | ||
|
|
164e6f83d8 | ||
|
|
0074848a4e | ||
|
|
8c09154183 | ||
|
|
c8339d5eae | ||
|
|
07e4991c56 | ||
|
|
4eb62e6c5f | ||
|
|
bf82556783 | ||
|
|
f282e84174 | ||
|
|
ed1a56dc68 | ||
|
|
af04831fb0 | ||
|
|
8f3eecd980 | ||
|
|
636c20d67b | ||
|
|
5d90950591 | ||
|
|
7651ce5e84 | ||
|
|
b3a1938c1d | ||
|
|
0ce1e1be27 | ||
|
|
470680d463 | ||
|
|
63e2fd447d | ||
|
|
864bcadcb2 | ||
|
|
ae852eb911 | ||
|
|
203fcd82e7 | ||
|
|
67469103d4 | ||
|
|
9e413000a5 | ||
|
|
bedcbfbb7e | ||
|
|
73e4970a43 | ||
|
|
ac8b7ae960 | ||
|
|
c62986e7b1 | ||
|
|
a3d5e955aa | ||
|
|
04e77e557b | ||
|
|
a748b54041 | ||
|
|
f77673a5c8 | ||
|
|
23ebc4d927 | ||
|
|
92b86674b9 | ||
|
|
f94d224395 | ||
|
|
822f498646 | ||
|
|
4566c1855b | ||
|
|
195bf8ed55 | ||
|
|
52678cfe35 | ||
|
|
61f89a2d4c | ||
|
|
ba398e4073 | ||
|
|
a14a4a8d60 | ||
|
|
9caf3b37ac | ||
|
|
e6965605c9 | ||
|
|
5b355a3e73 | ||
|
|
92d534a9c3 | ||
|
|
2d08f568e9 | ||
|
|
a9f7b1aeab | ||
|
|
4f53cfcb20 | ||
|
|
ffd70986b3 | ||
|
|
b5e89e47b6 | ||
|
|
439afd15fa | ||
|
|
8d2bfecb10 | ||
|
|
7d15c34ed2 | ||
|
|
4ef1b8dc4c | ||
|
|
c0d64058fd | ||
|
|
3c11ff63a7 | ||
|
|
537a50bee5 | ||
|
|
1a58b228a0 | ||
|
|
248a135f86 | ||
|
|
246fb69050 | ||
|
|
a907dd0084 | ||
|
|
fe58361724 | ||
|
|
a0f93a2dc3 | ||
|
|
82390ec9b9 | ||
|
|
34ef95926e | ||
|
|
faa0cba39d | ||
|
|
8b395bb29f | ||
|
|
31d7f237eb | ||
|
|
ef8c49f135 | ||
|
|
a8c460e715 | ||
|
|
d75e42e23d | ||
|
|
b191425112 | ||
|
|
c06a4ab76d | ||
|
|
b3042312f6 | ||
|
|
5fd105c9a9 | ||
|
|
306c2d143b | ||
|
|
a71a3e22e6 | ||
|
|
da4fab2f3e | ||
|
|
1db84efb68 | ||
|
|
c1c3af3c66 | ||
|
|
4622b9f202 | ||
|
|
47ea158c4c | ||
|
|
d9cb5eacf8 | ||
|
|
5718c47be7 | ||
|
|
d7d90e8e49 | ||
|
|
4d420b29cb | ||
|
|
0b9e7aa05b | ||
|
|
8c663cc84a | ||
|
|
8d1417c636 | ||
|
|
b91ab53219 | ||
|
|
6eeb7ae5b2 | ||
|
|
0436179020 | ||
|
|
c92cae51c8 | ||
|
|
57da68c0e2 | ||
|
|
67dc2ef9ab | ||
|
|
6459579f15 | ||
|
|
5112179fca | ||
|
|
b640564689 | ||
|
|
763f8938b3 | ||
|
|
4c67631ea5 | ||
|
|
920154b4b6 | ||
|
|
d8c7d10ed6 | ||
|
|
adcffa62d8 | ||
|
|
260d716eb8 | ||
|
|
fc1c26b5d7 | ||
|
|
df59c99f80 | ||
|
|
8a465edad9 | ||
|
|
9b9020b512 | ||
|
|
b9ac48532f | ||
|
|
886ef425e6 | ||
|
|
fbd61d2a21 | ||
|
|
469dcab5c7 | ||
|
|
fd1cb52f5f | ||
|
|
5175e68b99 | ||
|
|
64aa01b2cf | ||
|
|
63964ba6a7 | ||
|
|
e82683b0f4 | ||
|
|
4b2299ed02 | ||
|
|
4a4f89a992 | ||
|
|
d3a7bba666 | ||
|
|
8bd6582d07 | ||
|
|
875089305b | ||
|
|
c19ff71c9a | ||
|
|
36c320a584 | ||
|
|
316b8c56f1 | ||
|
|
2cf3855d35 | ||
|
|
13ec16c606 | ||
|
|
7d150e7e89 | ||
|
|
7d61948d91 | ||
|
|
73de3cc91d | ||
|
|
d71cbe344a | ||
|
|
dbe01a17d2 | ||
|
|
b3503cdede | ||
|
|
1ac6439690 | ||
|
|
c481841ddf | ||
|
|
678269c561 | ||
|
|
83e874cdb6 | ||
|
|
90fc5797d5 | ||
|
|
fcfcce88dd | ||
|
|
fb8fc54bb1 | ||
|
|
a4ec619e5a | ||
|
|
8fb2622b66 | ||
|
|
e6527de786 | ||
|
|
3f7e4d817f | ||
|
|
6eaabc84aa | ||
|
|
814557435d | ||
|
|
2d5328fc24 | ||
|
|
95c82c5cc5 | ||
|
|
fe907b0271 | ||
|
|
7ae74a6a18 | ||
|
|
9772049295 | ||
|
|
ec39f22ad8 | ||
|
|
51343a171d | ||
|
|
9988330613 | ||
|
|
8235717502 | ||
|
|
3d29d76cd4 | ||
|
|
cde5367f38 | ||
|
|
466463ebc3 | ||
|
|
aee255a6ee | ||
|
|
daf7598774 | ||
|
|
6de5cf8925 | ||
|
|
52e5817327 | ||
|
|
79ddfc65d5 | ||
|
|
6acb1fd92a | ||
|
|
bd2a1b8886 | ||
|
|
021cfd1737 | ||
|
|
5fc438a0be | ||
|
|
3a3ac83ab5 | ||
|
|
ff4570abac | ||
|
|
c819f2f0e3 | ||
|
|
c2cdb1264d | ||
|
|
c0e95ea18b | ||
|
|
f21a81d7ac | ||
|
|
67c726a141 | ||
|
|
ff5f50e3ec | ||
|
|
022a08f5a1 | ||
|
|
52f81274f0 | ||
|
|
35a50209be | ||
|
|
9728c136f5 | ||
|
|
bacd1d81fd | ||
|
|
f36227b506 | ||
|
|
debd840db4 | ||
|
|
1531629fcd | ||
|
|
7bc325fa08 | ||
|
|
2cc2d05c2f | ||
|
|
1df03e137b | ||
|
|
b0169b0edf | ||
|
|
ae79f03e61 | ||
|
|
77a587abe8 | ||
|
|
7f587dc389 | ||
|
|
8dbb03114d | ||
|
|
415e96dec6 | ||
|
|
abc7f135f3 | ||
|
|
4b93207def | ||
|
|
f004ae6a41 | ||
|
|
ac6120adc4 | ||
|
|
d9c2d58519 | ||
|
|
22865b8af4 | ||
|
|
431ba06742 | ||
|
|
ed8857552b | ||
|
|
5f42ca66fe | ||
|
|
c9d003ca6d | ||
|
|
330f40cc18 | ||
|
|
44366db4d5 | ||
|
|
93bf28b87d | ||
|
|
bd9c6834b7 | ||
|
|
96ad2bcdef | ||
|
|
14c03f0b37 | ||
|
|
5bd30fe3dc | ||
|
|
5be499887d | ||
|
|
7124963c56 | ||
|
|
4377808896 | ||
|
|
50f8f78b8d | ||
|
|
1bb9a13c17 | ||
|
|
e7f1b822f7 | ||
|
|
c9eee2e075 | ||
|
|
6556b3eb9b | ||
|
|
cc92f3829e | ||
|
|
33ffbe151f | ||
|
|
60db0ff775 | ||
|
|
7ecac185ac | ||
|
|
b76495fa8f | ||
|
|
463d4ad3fd | ||
|
|
0f0e41d5a4 | ||
|
|
2d55562dd3 | ||
|
|
cbe40fde92 | ||
|
|
97e62fdd34 | ||
|
|
aa799342e5 | ||
|
|
ae8cb18f63 | ||
|
|
8f53b6f233 | ||
|
|
9e385215ce | ||
|
|
378fbedfa4 | ||
|
|
c84e063114 | ||
|
|
8d316ce9f0 | ||
|
|
0019b93e8d | ||
|
|
2d66c6fb53 | ||
|
|
b629f674ca | ||
|
|
670622dfd7 | ||
|
|
f35c2ead0f | ||
|
|
6b7b797089 | ||
|
|
15d4f6354d | ||
|
|
77cea99b35 | ||
|
|
70b50bd096 | ||
|
|
c2401e7a75 | ||
|
|
b63c6223b0 | ||
|
|
bcf10cc0b2 | ||
|
|
aaa73eb196 | ||
|
|
707c7a1a53 | ||
|
|
dcb3e1c0e4 | ||
|
|
894110ba08 | ||
|
|
a4dceb0b74 | ||
|
|
1c82fdf048 | ||
|
|
ec303e485f | ||
|
|
1cdcc6d190 | ||
|
|
ef1f44f873 | ||
|
|
00e81e87de | ||
|
|
2f082b9f85 | ||
|
|
c9e5230e37 | ||
|
|
86e2657613 | ||
|
|
c23470af40 | ||
|
|
6c5ec3d2e9 | ||
|
|
38423a9f37 | ||
|
|
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 |
4
.github/auto-comment.yml
vendored
Normal file
4
.github/auto-comment.yml
vendored
Normal file
@@ -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!
|
||||
8
.github/no-response.yml
vendored
Normal file
8
.github/no-response.yml
vendored
Normal file
@@ -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.
|
||||
14
.github/stale.yml
vendored
Normal file
14
.github/stale.yml
vendored
Normal file
@@ -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
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,6 +1,10 @@
|
||||
#built application files
|
||||
# built application files
|
||||
*.apk
|
||||
*.ap_
|
||||
*.aab
|
||||
output.json
|
||||
output-metadata.json
|
||||
out/
|
||||
|
||||
# files for the dex VM
|
||||
*.dex
|
||||
@@ -30,8 +34,11 @@ Thumbs.db
|
||||
#.idea/workspace.xml - remove # and delete .idea if it better suit your needs.
|
||||
.gradle
|
||||
build/
|
||||
app/app.iml
|
||||
*.iml
|
||||
|
||||
# Compiled JNI libraries folder
|
||||
**/jniLibs
|
||||
app/.externalNativeBuild/
|
||||
|
||||
# NDK stuff
|
||||
.cxx/
|
||||
6
.gitmodules
vendored
6
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
||||
[submodule "moonlight-common"]
|
||||
path = moonlight-common
|
||||
url = https://github.com/moonlight-stream/moonlight-common.git
|
||||
[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
|
||||
|
||||
18
.travis.yml
Normal file
18
.travis.yml
Normal file
@@ -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"
|
||||
55
README.md
55
README.md
@@ -1,56 +1,27 @@
|
||||
# Moonlight
|
||||
# Moonlight Android
|
||||
|
||||
[Moonlight](http://moonlight-stream.com) 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)
|
||||
|
||||
Moonlight will allow you to stream your full collection of games from your Windows PC to your Android device,
|
||||
[Moonlight for Android](https://moonlight-stream.org) is an open source implementation of NVIDIA's GameStream, as used by the NVIDIA Shield.
|
||||
|
||||
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.
|
||||
|
||||
[Moonlight-pc](https://github.com/moonlight-stream/moonlight-pc) is also currently in development for Windows, OS X and Linux. Versions for [iOS](https://github.com/moonlight-stream/moonlight-ios) and [Windows and Windows Phone](https://github.com/moonlight-stream/moonlight-windows) are also in development.
|
||||
Moonlight also has a [PC client](https://github.com/moonlight-stream/moonlight-qt) and [iOS/tvOS client](https://github.com/moonlight-stream/moonlight-ios).
|
||||
|
||||
Check our [wiki](https://github.com/moonlight-stream/moonlight-docs/wiki) for more detailed information or a troubleshooting guide.
|
||||
Check out [the Moonlight wiki](https://github.com/moonlight-stream/moonlight-docs/wiki) for more detailed project information, setup guide, or troubleshooting steps.
|
||||
|
||||
## Features
|
||||
|
||||
* Streams any of your games from your PC to your Android device
|
||||
* Full gamepad support for MOGA, Xbox 360, PS3, OUYA, and Shield
|
||||
* Automatically finds GameStream-compatible PCs on your network
|
||||
|
||||
## Installation
|
||||
|
||||
* Download and install Moonlight for Android from
|
||||
[Google Play](https://play.google.com/store/apps/details?id=com.limelight), [Amazon App Store](http://www.amazon.com/gp/product/B00JK4MFN2), or directly from the [releases page](https://github.com/moonlight-stream/moonlight-android/releases)
|
||||
* Download [GeForce Experience](http://www.geforce.com/geforce-experience) and install on your Windows PC
|
||||
|
||||
## Requirements
|
||||
|
||||
* [GameStream compatible](http://shield.nvidia.com/play-pc-games/) computer with an NVIDIA GeForce GTX 600 series or higher desktop or mobile GPU (GT-series and AMD GPUs not supported)
|
||||
* Android device running 4.1 (Jelly Bean) or higher
|
||||
* High-end wireless router (802.11n dual-band recommended)
|
||||
|
||||
## Usage
|
||||
|
||||
* Turn on GameStream in the GFE settings
|
||||
* If you are connecting from outside the same network, turn on internet
|
||||
streaming
|
||||
* When on the same network as your PC, open Moonlight and tap on your PC in the list
|
||||
* Accept the pairing confirmation on your PC and add the PIN if needed
|
||||
* Tap your PC again to view the list of apps to stream
|
||||
* Play games!
|
||||
|
||||
## Contribute
|
||||
|
||||
This project is being actively developed at [XDA Developers](http://forum.xda-developers.com/showthread.php?t=2505510)
|
||||
|
||||
1. Fork us
|
||||
2. Write code
|
||||
3. Send Pull Requests
|
||||
## 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)
|
||||
|
||||
## 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
|
||||
* Build the APK using Android Studio or gradle
|
||||
|
||||
## Authors
|
||||
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import com.android.builder.model.ProductFlavor
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
compileSdkVersion 27
|
||||
buildToolsVersion '27.0.1'
|
||||
compileSdkVersion 30
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 27
|
||||
targetSdkVersion 30
|
||||
|
||||
versionName "5.6.1"
|
||||
versionCode = 139
|
||||
versionName "9.7.2"
|
||||
versionCode = 242
|
||||
}
|
||||
|
||||
flavorDimensions "root"
|
||||
@@ -47,15 +43,66 @@ android {
|
||||
|
||||
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 {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt')
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +114,10 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.bouncycastle:bcprov-jdk15on:1.57'
|
||||
implementation 'org.bouncycastle:bcpkix-jdk15on:1.57'
|
||||
implementation files('libs/jcodec-0.1.9-patched.jar')
|
||||
|
||||
implementation project(':moonlight-common')
|
||||
implementation 'org.bouncycastle:bcprov-jdk15on:1.64'
|
||||
implementation 'org.bouncycastle:bcpkix-jdk15on:1.64'
|
||||
implementation 'org.jcodec:jcodec:0.2.3'
|
||||
implementation 'com.squareup.okhttp3:okhttp:3.12.10'
|
||||
implementation 'com.squareup.okio:okio:1.17.5'
|
||||
implementation 'org.jmdns:jmdns:3.5.5'
|
||||
}
|
||||
|
||||
Binary file not shown.
6
app/lint.xml
Normal file
6
app/lint.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lint>
|
||||
<issue id="InvalidPackage">
|
||||
<ignore path="**/bcpkix-jdk15on-*.jar"/>
|
||||
</issue>
|
||||
</lint>
|
||||
28
app/proguard-rules.pro
vendored
Normal file
28
app/proguard-rules.pro
vendored
Normal file
@@ -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.**
|
||||
7
app/src/debug/res/values/strings.xml
Normal file
7
app/src/debug/res/values/strings.xml
Normal file
@@ -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>
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
<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"
|
||||
@@ -32,13 +35,21 @@
|
||||
<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"
|
||||
@@ -66,8 +77,9 @@
|
||||
</activity>
|
||||
<!-- Small hack to support launcher shortcuts without relaunching over and over again when the back button is pressed -->
|
||||
<activity
|
||||
android:name=".AppViewShortcutTrampoline"
|
||||
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
|
||||
@@ -93,21 +105,24 @@
|
||||
<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="sensorLandscape"
|
||||
android:screenOrientation="userLandscape"
|
||||
android:noHistory="true"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@style/StreamTheme">
|
||||
android:theme="@style/StreamTheme"
|
||||
android:preferMinimalPostProcessing="true">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="com.limelight.AppView" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.limelight;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.limelight.computers.ComputerManagerListener;
|
||||
import com.limelight.computers.ComputerManagerService;
|
||||
@@ -26,6 +27,11 @@ 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;
|
||||
@@ -36,10 +42,13 @@ 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;
|
||||
@@ -52,14 +61,22 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
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 CANCEL_ID = 3;
|
||||
private final static int START_WTIH_QUIT = 4;
|
||||
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() {
|
||||
@@ -74,27 +91,35 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
// Wait for the binder to be ready
|
||||
localBinder.waitForReady();
|
||||
|
||||
// Now make the binder visible
|
||||
managerBinder = localBinder;
|
||||
|
||||
// Get the computer object
|
||||
computer = managerBinder.getComputer(UUID.fromString(uuidString));
|
||||
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).listMode,
|
||||
PreferenceConfiguration.readPreferences(AppView.this).smallIconMode,
|
||||
computer, managerBinder.getUniqueId());
|
||||
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
|
||||
@@ -132,6 +157,27 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
}
|
||||
};
|
||||
|
||||
@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) {
|
||||
@@ -147,7 +193,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
}
|
||||
|
||||
// Don't care about other computers
|
||||
if (!details.uuid.toString().equalsIgnoreCase(uuidString)) {
|
||||
if (!details.uuid.equalsIgnoreCase(uuidString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,7 +217,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
@Override
|
||||
public void run() {
|
||||
// Disable shortcuts referencing this PC for now
|
||||
shortcutHelper.disableShortcut(details.uuid.toString(),
|
||||
shortcutHelper.disableComputerShortcut(details,
|
||||
getResources().getString(R.string.scut_not_paired));
|
||||
|
||||
// Display a toast to the user and quit the activity
|
||||
@@ -207,7 +253,9 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
blockingLoadSpinner.dismiss();
|
||||
blockingLoadSpinner = null;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -247,24 +295,40 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
|
||||
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);
|
||||
|
||||
String labelText = getResources().getString(R.string.title_applist)+" "+computerName;
|
||||
TextView label = findViewById(R.id.appListText);
|
||||
setTitle(labelText);
|
||||
label.setText(labelText);
|
||||
|
||||
// Add a launcher shortcut for this PC (forced, since this is user interaction)
|
||||
shortcutHelper.createAppViewShortcut(uuidString, computerName, uuidString, true);
|
||||
shortcutHelper.reportShortcutUsed(uuidString);
|
||||
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
|
||||
@@ -272,7 +336,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
List<NvApp> applist = NvHTTP.getAppListByReader(new StringReader(lastRawApplist));
|
||||
updateUiWithAppList(applist);
|
||||
LimeLog.info("Loaded applist from cache");
|
||||
} catch (Exception e) {
|
||||
} catch (IOException | XmlPullParserException e) {
|
||||
if (lastRawApplist != null) {
|
||||
LimeLog.warning("Saved applist corrupted: "+lastRawApplist);
|
||||
e.printStackTrace();
|
||||
@@ -331,8 +395,30 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit));
|
||||
}
|
||||
else {
|
||||
menu.add(Menu.NONE, START_WTIH_QUIT, 1, getResources().getString(R.string.applist_menu_quit_and_start));
|
||||
menu.add(Menu.NONE, CANCEL_ID, 2, getResources().getString(R.string.applist_menu_cancel));
|
||||
menu.add(Menu.NONE, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,7 +432,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo();
|
||||
final AppObject app = (AppObject) appGridAdapter.getItem(info.position);
|
||||
switch (item.getItemId()) {
|
||||
case START_WTIH_QUIT:
|
||||
case START_WITH_QUIT:
|
||||
// Display a confirmation dialog first
|
||||
UiHelper.displayQuitConfirmationDialog(this, new Runnable() {
|
||||
@Override
|
||||
@@ -367,8 +453,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
@Override
|
||||
public void run() {
|
||||
suspendGridUpdates = true;
|
||||
ServerHelper.doQuit(AppView.this,
|
||||
ServerHelper.getCurrentAddressFromComputer(computer),
|
||||
ServerHelper.doQuit(AppView.this, computer,
|
||||
app.app, managerBinder, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@@ -383,7 +468,28 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
}, null);
|
||||
return true;
|
||||
|
||||
case CANCEL_ID:
|
||||
case VIEW_DETAILS_ID:
|
||||
Dialog.displayDialog(AppView.this, getResources().getString(R.string.title_details), app.app.toString(), false);
|
||||
return true;
|
||||
|
||||
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:
|
||||
@@ -457,6 +563,12 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -477,6 +589,7 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -498,9 +611,8 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
|
||||
@Override
|
||||
public int getAdapterFragmentLayoutId() {
|
||||
return PreferenceConfiguration.readPreferences(this).listMode ?
|
||||
R.layout.list_view : (PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ?
|
||||
R.layout.app_grid_view_small : R.layout.app_grid_view);
|
||||
return PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ?
|
||||
R.layout.app_grid_view_small : R.layout.app_grid_view;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -520,13 +632,15 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
|
||||
}
|
||||
}
|
||||
});
|
||||
UiHelper.applyStatusBarPadding(listView);
|
||||
registerForContextMenu(listView);
|
||||
listView.requestFocus();
|
||||
}
|
||||
|
||||
public class AppObject {
|
||||
public static class AppObject {
|
||||
public final NvApp app;
|
||||
public boolean isRunning;
|
||||
public boolean isHidden;
|
||||
|
||||
public AppObject(NvApp app) {
|
||||
if (app == null) {
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
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.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 AppViewShortcutTrampoline extends Activity {
|
||||
private String uuidString;
|
||||
|
||||
private ComputerDetails computer;
|
||||
private SpinnerDialog blockingLoadSpinner;
|
||||
|
||||
public final static String UUID_EXTRA = "UUID";
|
||||
|
||||
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(UUID.fromString(uuidString));
|
||||
|
||||
// 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.toString().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 (details.state == ComputerDetails.State.ONLINE) {
|
||||
// Close this activity
|
||||
finish();
|
||||
|
||||
// Create a new activity stack for this launch
|
||||
ArrayList<Intent> intentStack = new ArrayList<>();
|
||||
Intent i;
|
||||
|
||||
// Add the PC view at the back (and clear the task)
|
||||
i = new Intent(AppViewShortcutTrampoline.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(AppViewShortcutTrampoline.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(AppViewShortcutTrampoline.this,
|
||||
new NvApp("app", 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(AppViewShortcutTrampoline.this,
|
||||
getResources().getString(R.string.conn_error_title),
|
||||
getResources().getString(R.string.error_pc_offline),
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
UiHelper.notifyNewRootView(this);
|
||||
|
||||
uuidString = getIntent().getStringExtra(UUID_EXTRA);
|
||||
|
||||
// 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 onPause() {
|
||||
super.onPause();
|
||||
|
||||
if (blockingLoadSpinner != null) {
|
||||
blockingLoadSpinner.dismiss();
|
||||
blockingLoadSpinner = null;
|
||||
}
|
||||
|
||||
Dialog.closeDialogs();
|
||||
|
||||
if (managerBinder != null) {
|
||||
managerBinder.stopPolling();
|
||||
unbindService(serviceConnection);
|
||||
managerBinder = null;
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
25
app/src/main/java/com/limelight/LimeLog.java
Normal file
25
app/src/main/java/com/limelight/LimeLog.java
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -28,6 +29,7 @@ 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;
|
||||
@@ -52,6 +54,8 @@ 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;
|
||||
|
||||
@@ -112,6 +116,9 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
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);
|
||||
@@ -121,6 +128,9 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
// 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);
|
||||
@@ -218,9 +228,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
bindService(new Intent(PcView.this, ComputerManagerService.class), serviceConnection,
|
||||
Service.BIND_AUTO_CREATE);
|
||||
|
||||
pcGridAdapter = new PcGridAdapter(this,
|
||||
PreferenceConfiguration.readPreferences(this).listMode,
|
||||
PreferenceConfiguration.readPreferences(this).smallIconMode);
|
||||
pcGridAdapter = new PcGridAdapter(this, PreferenceConfiguration.readPreferences(this));
|
||||
|
||||
initializeViews();
|
||||
}
|
||||
@@ -311,14 +319,12 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position);
|
||||
|
||||
// Inflate the context menu
|
||||
if (computer.details.reachability == ComputerDetails.Reachability.OFFLINE ||
|
||||
computer.details.reachability == ComputerDetails.Reachability.UNKNOWN) {
|
||||
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));
|
||||
menu.add(Menu.NONE, DELETE_ID, 2, getResources().getString(R.string.pcview_menu_delete_pc));
|
||||
}
|
||||
else if (computer.details.pairState != PairState.PAIRED) {
|
||||
menu.add(Menu.NONE, PAIR_ID, 1, getResources().getString(R.string.pcview_menu_pair_pc));
|
||||
menu.add(Menu.NONE, DELETE_ID, 2, getResources().getString(R.string.pcview_menu_delete_pc));
|
||||
}
|
||||
else {
|
||||
if (computer.details.runningGameId != 0) {
|
||||
@@ -327,11 +333,12 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
}
|
||||
|
||||
menu.add(Menu.NONE, APP_LIST_ID, 3, getResources().getString(R.string.pcview_menu_app_list));
|
||||
|
||||
// FIXME: We used to be able to unpair here but it's been broken since GFE 2.1.x, so I've replaced
|
||||
// it with delete which actually work
|
||||
menu.add(Menu.NONE, DELETE_ID, 4, getResources().getString(R.string.pcview_menu_delete_pc));
|
||||
menu.add(Menu.NONE, FULL_APP_LIST_ID, 4, getResources().getString(R.string.pcview_menu_full_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
|
||||
@@ -343,7 +350,8 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
}
|
||||
|
||||
private void doPair(final ComputerDetails computer) {
|
||||
if (computer.reachability == ComputerDetails.Reachability.OFFLINE) {
|
||||
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;
|
||||
}
|
||||
@@ -369,9 +377,9 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
|
||||
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
||||
managerBinder.getUniqueId(),
|
||||
PlatformBinding.getDeviceName(),
|
||||
computer.serverCert,
|
||||
PlatformBinding.getCryptoProvider(PcView.this));
|
||||
if (httpConn.getPairState() == PairingManager.PairState.PAIRED) {
|
||||
if (httpConn.getPairState() == PairState.PAIRED) {
|
||||
// Don't display any toast, but open the app list
|
||||
message = null;
|
||||
success = true;
|
||||
@@ -383,21 +391,26 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title),
|
||||
getResources().getString(R.string.pair_pairing_msg)+" "+pinStr, false);
|
||||
|
||||
PairingManager.PairState pairState = httpConn.pair(httpConn.getServerInfo(), pinStr);
|
||||
if (pairState == PairingManager.PairState.PIN_WRONG) {
|
||||
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 == PairingManager.PairState.FAILED) {
|
||||
else if (pairState == PairState.FAILED) {
|
||||
message = getResources().getString(R.string.pair_fail);
|
||||
}
|
||||
else if (pairState == PairingManager.PairState.ALREADY_IN_PROGRESS) {
|
||||
else if (pairState == PairState.ALREADY_IN_PROGRESS) {
|
||||
message = getResources().getString(R.string.pair_already_in_progress);
|
||||
}
|
||||
else if (pairState == PairingManager.PairState.PAIRED) {
|
||||
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);
|
||||
@@ -411,7 +424,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
message = getResources().getString(R.string.error_unknown_host);
|
||||
} catch (FileNotFoundException e) {
|
||||
message = getResources().getString(R.string.error_404);
|
||||
} catch (Exception e) {
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
e.printStackTrace();
|
||||
message = e.getMessage();
|
||||
}
|
||||
@@ -429,7 +442,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
|
||||
if (toastSuccess) {
|
||||
// Open the app list after a successful pairing attempt
|
||||
doAppList(computer);
|
||||
doAppList(computer, true, false);
|
||||
}
|
||||
else {
|
||||
// Start polling again if we're still in the foreground
|
||||
@@ -452,7 +465,6 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.wol_waking_pc), Toast.LENGTH_SHORT).show();
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@@ -476,7 +488,8 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
}
|
||||
|
||||
private void doUnpair(final ComputerDetails computer) {
|
||||
if (computer.reachability == ComputerDetails.Reachability.OFFLINE) {
|
||||
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;
|
||||
}
|
||||
@@ -494,7 +507,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
try {
|
||||
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
||||
managerBinder.getUniqueId(),
|
||||
PlatformBinding.getDeviceName(),
|
||||
computer.serverCert,
|
||||
PlatformBinding.getCryptoProvider(PcView.this));
|
||||
if (httpConn.getPairState() == PairingManager.PairState.PAIRED) {
|
||||
httpConn.unpair();
|
||||
@@ -512,8 +525,9 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
message = getResources().getString(R.string.error_unknown_host);
|
||||
} catch (FileNotFoundException e) {
|
||||
message = getResources().getString(R.string.error_404);
|
||||
} catch (Exception e) {
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
message = e.getMessage();
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
final String toastMessage = message;
|
||||
@@ -527,8 +541,8 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void doAppList(ComputerDetails computer) {
|
||||
if (computer.reachability == ComputerDetails.Reachability.OFFLINE) {
|
||||
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;
|
||||
}
|
||||
@@ -539,7 +553,9 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
|
||||
Intent i = new Intent(this, AppView.class);
|
||||
i.putExtra(AppView.NAME_EXTRA, computer.name);
|
||||
i.putExtra(AppView.UUID_EXTRA, computer.uuid.toString());
|
||||
i.putExtra(AppView.UUID_EXTRA, computer.uuid);
|
||||
i.putExtra(AppView.NEW_PAIR_EXTRA, newlyPaired);
|
||||
i.putExtra(AppView.SHOW_HIDDEN_APPS_EXTRA, showHiddenGames);
|
||||
startActivity(i);
|
||||
}
|
||||
|
||||
@@ -561,16 +577,25 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
return true;
|
||||
|
||||
case DELETE_ID:
|
||||
if (managerBinder == null) {
|
||||
Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
|
||||
if (ActivityManager.isUserAMonkey()) {
|
||||
LimeLog.info("Ignoring delete PC request from monkey");
|
||||
return true;
|
||||
}
|
||||
managerBinder.removeComputer(computer.details.name);
|
||||
removeComputer(computer.details);
|
||||
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:
|
||||
case APP_LIST_ID:
|
||||
doAppList(computer.details);
|
||||
doAppList(computer.details, false, item.getItemId() == FULL_APP_LIST_ID);
|
||||
return true;
|
||||
|
||||
case RESUME_ID:
|
||||
@@ -592,25 +617,42 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
UiHelper.displayQuitConfirmationDialog(this, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
ServerHelper.doQuit(PcView.this,
|
||||
ServerHelper.getCurrentAddressFromComputer(computer.details),
|
||||
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.disableShortcut(details.uuid.toString(),
|
||||
shortcutHelper.disableComputerShortcut(details,
|
||||
getResources().getString(R.string.scut_deleted_pc));
|
||||
|
||||
pcGridAdapter.removeComputer(computer);
|
||||
@@ -641,7 +683,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
|
||||
// Add a launcher shortcut for this PC
|
||||
if (details.pairState == PairState.PAIRED) {
|
||||
shortcutHelper.createAppViewShortcut(details.uuid.toString(), details, false);
|
||||
shortcutHelper.createAppViewShortcutForOnlineHost(details);
|
||||
}
|
||||
|
||||
if (existingEntry != null) {
|
||||
@@ -662,9 +704,7 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
|
||||
@Override
|
||||
public int getAdapterFragmentLayoutId() {
|
||||
return PreferenceConfiguration.readPreferences(this).listMode ?
|
||||
R.layout.list_view : (PreferenceConfiguration.readPreferences(this).smallIconMode ?
|
||||
R.layout.pc_grid_view_small : R.layout.pc_grid_view);
|
||||
return R.layout.pc_grid_view;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -675,22 +715,23 @@ public class PcView extends Activity implements AdapterFragmentCallbacks {
|
||||
public void onItemClick(AdapterView<?> arg0, View arg1, int pos,
|
||||
long id) {
|
||||
ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(pos);
|
||||
if (computer.details.reachability == ComputerDetails.Reachability.UNKNOWN ||
|
||||
computer.details.reachability == ComputerDetails.Reachability.OFFLINE) {
|
||||
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);
|
||||
doAppList(computer.details, false, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
UiHelper.applyStatusBarPadding(listView);
|
||||
registerForContextMenu(listView);
|
||||
}
|
||||
|
||||
public class ComputerObject {
|
||||
public static class ComputerObject {
|
||||
public ComputerDetails details;
|
||||
|
||||
public ComputerObject(ComputerDetails details) {
|
||||
|
||||
107
app/src/main/java/com/limelight/PosterContentProvider.java
Normal file
107
app/src/main/java/com/limelight/PosterContentProvider.java
Normal file
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
277
app/src/main/java/com/limelight/ShortcutTrampoline.java
Normal file
277
app/src/main/java/com/limelight/ShortcutTrampoline.java
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,10 @@ public class AndroidAudioRenderer implements AudioRenderer {
|
||||
|
||||
private AudioTrack track;
|
||||
|
||||
private AudioTrack createAudioTrack(int channelConfig, int bufferSize, boolean lowLatency) {
|
||||
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,
|
||||
48000,
|
||||
sampleRate,
|
||||
channelConfig,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize,
|
||||
@@ -28,7 +28,7 @@ public class AndroidAudioRenderer implements AudioRenderer {
|
||||
.setUsage(AudioAttributes.USAGE_GAME);
|
||||
AudioFormat format = new AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(48000)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(channelConfig)
|
||||
.build();
|
||||
|
||||
@@ -64,25 +64,46 @@ public class AndroidAudioRenderer implements AudioRenderer {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int setup(int audioConfiguration) {
|
||||
public int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame) {
|
||||
int channelConfig;
|
||||
int bytesPerFrame;
|
||||
|
||||
switch (audioConfiguration)
|
||||
switch (audioConfiguration.channelCount)
|
||||
{
|
||||
case MoonBridge.AUDIO_CONFIGURATION_STEREO:
|
||||
case 2:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
|
||||
bytesPerFrame = 2 * 240 * 2;
|
||||
break;
|
||||
case MoonBridge.AUDIO_CONFIGURATION_51_SURROUND:
|
||||
case 4:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_QUAD;
|
||||
break;
|
||||
case 6:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
|
||||
bytesPerFrame = 6 * 240 * 2;
|
||||
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.
|
||||
@@ -122,7 +143,7 @@ public class AndroidAudioRenderer implements AudioRenderer {
|
||||
case 1:
|
||||
case 3:
|
||||
// Try the larger buffer size
|
||||
bufferSize = Math.max(AudioTrack.getMinBufferSize(48000,
|
||||
bufferSize = Math.max(AudioTrack.getMinBufferSize(sampleRate,
|
||||
channelConfig,
|
||||
AudioFormat.ENCODING_PCM_16BIT),
|
||||
bytesPerFrame * 2);
|
||||
@@ -135,13 +156,13 @@ public class AndroidAudioRenderer implements AudioRenderer {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
|
||||
// Skip low latency options if hardware sample rate isn't 48000Hz
|
||||
if (AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC) != 48000 && lowLatency) {
|
||||
// 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, bufferSize, lowLatency);
|
||||
track = createAudioTrack(channelConfig, sampleRate, bufferSize, lowLatency);
|
||||
track.play();
|
||||
|
||||
// Successfully created working AudioTrack. We're done here.
|
||||
@@ -168,22 +189,31 @@ public class AndroidAudioRenderer implements AudioRenderer {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playDecodedAudio(byte[] audioData) {
|
||||
track.write(audioData, 0, audioData.length);
|
||||
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() {
|
||||
// Immediately drop all pending data
|
||||
track.pause();
|
||||
track.flush();
|
||||
}
|
||||
public void stop() {}
|
||||
|
||||
@Override
|
||||
public void cleanup() {
|
||||
// Immediately drop all pending data
|
||||
track.pause();
|
||||
track.flush();
|
||||
|
||||
track.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.NoSuchProviderException;
|
||||
import java.security.Provider;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Security;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
@@ -54,10 +53,7 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
||||
|
||||
private static final Object globalCryptoLock = new Object();
|
||||
|
||||
static {
|
||||
// Install the Bouncy Castle provider
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
private static final Provider bcProvider = new BouncyCastleProvider();
|
||||
|
||||
public AndroidCryptoProvider(Context c) {
|
||||
String dataPath = c.getFilesDir().getAbsolutePath();
|
||||
@@ -96,27 +92,21 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
||||
}
|
||||
|
||||
try {
|
||||
CertificateFactory certFactory = CertificateFactory.getInstance("X.509", "BC");
|
||||
CertificateFactory certFactory = CertificateFactory.getInstance("X.509", bcProvider);
|
||||
cert = (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
pemCertBytes = certBytes;
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA", "BC");
|
||||
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) {
|
||||
// Should never happen
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
throw new RuntimeException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
// May happen if the key is corrupt
|
||||
LimeLog.warning("Corrupted key");
|
||||
return false;
|
||||
} catch (NoSuchProviderException e) {
|
||||
// Should never happen
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -129,17 +119,11 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
||||
|
||||
KeyPair keyPair;
|
||||
try {
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", bcProvider);
|
||||
keyPairGenerator.initialize(2048);
|
||||
keyPair = keyPairGenerator.generateKeyPair();
|
||||
} catch (NoSuchAlgorithmException e1) {
|
||||
// Should never happen
|
||||
e1.printStackTrace();
|
||||
return false;
|
||||
} catch (NoSuchProviderException e) {
|
||||
// Should never happen
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
Date now = new Date();
|
||||
@@ -160,13 +144,11 @@ public class AndroidCryptoProvider implements LimelightCryptoProvider {
|
||||
SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded()));
|
||||
|
||||
try {
|
||||
ContentSigner sigGen = new JcaContentSignerBuilder("SHA1withRSA").setProvider(BouncyCastleProvider.PROVIDER_NAME).build(keyPair.getPrivate());
|
||||
cert = new JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME).getCertificate(certBuilder.build(sigGen));
|
||||
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) {
|
||||
// Nothing should go wrong here
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
LimeLog.info("Generated a new key pair");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,263 +8,290 @@ import android.view.KeyEvent;
|
||||
* @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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
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_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;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
case KeyEvent.KEYCODE_META_LEFT:
|
||||
translated = 0x5b;
|
||||
break;
|
||||
/**
|
||||
* 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_META_RIGHT:
|
||||
translated = 0x5c;
|
||||
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_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_CTRL_RIGHT:
|
||||
translated = 0xA3;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_DEL:
|
||||
translated = VK_BACK_SPACE;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
translated = 0x0d;
|
||||
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_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_NUMPAD_DIVIDE:
|
||||
translated = 0x6F;
|
||||
break;
|
||||
case KeyEvent.KEYCODE_META_LEFT:
|
||||
translated = 0x5b;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_NUMPAD_MULTIPLY:
|
||||
translated = 0x6A;
|
||||
break;
|
||||
case KeyEvent.KEYCODE_META_RIGHT:
|
||||
translated = 0x5c;
|
||||
break;
|
||||
|
||||
case KeyEvent.KEYCODE_NUMPAD_SUBTRACT:
|
||||
translated = 0x6D;
|
||||
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_NUMPAD_ADD:
|
||||
translated = 0x6B;
|
||||
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_DOT:
|
||||
translated = 0x6E;
|
||||
break;
|
||||
|
||||
default:
|
||||
System.out.println("No key for "+keycode);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return (short) ((KEY_PREFIX << 8) | translated);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
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;
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
public class AndroidNativePointerCaptureProvider extends InputCaptureProvider {
|
||||
|
||||
// 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(View targetView) {
|
||||
public AndroidNativePointerCaptureProvider(Activity activity, View targetView) {
|
||||
super(activity, targetView);
|
||||
this.targetView = targetView;
|
||||
}
|
||||
|
||||
@@ -31,11 +36,6 @@ public class AndroidNativePointerCaptureProvider extends InputCaptureProvider {
|
||||
targetView.releasePointerCapture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCapturingActive() {
|
||||
return targetView.hasPointerCapture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
||||
return event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE;
|
||||
@@ -43,11 +43,19 @@ public class AndroidNativePointerCaptureProvider extends InputCaptureProvider {
|
||||
|
||||
@Override
|
||||
public float getRelativeAxisX(MotionEvent event) {
|
||||
return event.getX();
|
||||
float x = event.getX();
|
||||
for (int i = 0; i < event.getHistorySize(); i++) {
|
||||
x += event.getHistoricalX(i);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getRelativeAxisY(MotionEvent event) {
|
||||
return event.getY();
|
||||
float y = event.getY();
|
||||
for (int i = 0; i < event.getHistorySize(); i++) {
|
||||
y += event.getHistoricalY(i);
|
||||
}
|
||||
return y;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,55 +7,30 @@ import android.os.Build;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.PointerIcon;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.N)
|
||||
public class AndroidPointerIconCaptureProvider extends InputCaptureProvider {
|
||||
private ViewGroup rootViewGroup;
|
||||
private View targetView;
|
||||
private Context context;
|
||||
|
||||
public AndroidPointerIconCaptureProvider(Activity activity) {
|
||||
public AndroidPointerIconCaptureProvider(Activity activity, View targetView) {
|
||||
this.context = activity;
|
||||
this.rootViewGroup = (ViewGroup) activity.getWindow().getDecorView();
|
||||
this.targetView = targetView;
|
||||
}
|
||||
|
||||
public static boolean isCaptureProviderSupported() {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N;
|
||||
}
|
||||
|
||||
private void setPointerIconOnAllViews(PointerIcon icon) {
|
||||
for (int i = 0; i < rootViewGroup.getChildCount(); i++) {
|
||||
View view = rootViewGroup.getChildAt(i);
|
||||
view.setPointerIcon(icon);
|
||||
}
|
||||
rootViewGroup.setPointerIcon(icon);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableCapture() {
|
||||
super.enableCapture();
|
||||
setPointerIconOnAllViews(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL));
|
||||
targetView.setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableCapture() {
|
||||
super.disableCapture();
|
||||
setPointerIconOnAllViews(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
||||
return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X) != 0 ||
|
||||
event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y) != 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getRelativeAxisX(MotionEvent event) {
|
||||
return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getRelativeAxisY(MotionEvent event) {
|
||||
return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
|
||||
targetView.setPointerIcon(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -11,9 +12,11 @@ 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.findViewById(R.id.surfaceView));
|
||||
return new AndroidNativePointerCaptureProvider(activity, activity.findViewById(R.id.surfaceView));
|
||||
}
|
||||
else if (ShieldCaptureProvider.isCaptureProviderSupported()) {
|
||||
// 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);
|
||||
}
|
||||
@@ -25,7 +28,7 @@ public class InputCaptureManager {
|
||||
// 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);
|
||||
return new AndroidPointerIconCaptureProvider(activity, activity.findViewById(R.id.surfaceView));
|
||||
}
|
||||
else {
|
||||
LimeLog.info("Mouse capture not available");
|
||||
|
||||
@@ -75,8 +75,10 @@ public class ShieldCaptureProvider extends InputCaptureProvider {
|
||||
|
||||
@Override
|
||||
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
|
||||
return event.getAxisValue(AXIS_RELATIVE_X) != 0 ||
|
||||
event.getAxisValue(AXIS_RELATIVE_Y) != 0;
|
||||
// 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
|
||||
|
||||
@@ -3,6 +3,8 @@ 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;
|
||||
|
||||
@@ -15,6 +17,14 @@ public abstract class AbstractController {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public int getVendorId() {
|
||||
return vendorId;
|
||||
}
|
||||
|
||||
public int getProductId() {
|
||||
return productId;
|
||||
}
|
||||
|
||||
protected void setButtonFlag(int buttonFlag, int data) {
|
||||
if (data != 0) {
|
||||
buttonFlags |= buttonFlag;
|
||||
@@ -32,16 +42,20 @@ public abstract class AbstractController {
|
||||
public abstract boolean start();
|
||||
public abstract void stop();
|
||||
|
||||
public AbstractController(int deviceId, UsbDriverListener listener) {
|
||||
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(deviceId);
|
||||
listener.deviceRemoved(this);
|
||||
}
|
||||
|
||||
protected void notifyDeviceAdded() {
|
||||
listener.deviceAdded(deviceId);
|
||||
listener.deviceAdded(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public abstract class AbstractXboxController extends AbstractController {
|
||||
protected UsbEndpoint inEndpt, outEndpt;
|
||||
|
||||
public AbstractXboxController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
|
||||
super(deviceId, listener);
|
||||
super(deviceId, listener, device.getVendorId(), device.getProductId());
|
||||
this.device = device;
|
||||
this.connection = connection;
|
||||
}
|
||||
@@ -30,6 +30,18 @@ public abstract class AbstractXboxController extends AbstractController {
|
||||
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];
|
||||
|
||||
@@ -114,9 +126,6 @@ public abstract class AbstractXboxController extends AbstractController {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Report that we're added _before_ starting the input thread
|
||||
notifyDeviceAdded();
|
||||
|
||||
// Start listening for controller input
|
||||
inputThread = createInputThread();
|
||||
inputThread.start();
|
||||
@@ -131,6 +140,9 @@ public abstract class AbstractXboxController extends AbstractController {
|
||||
|
||||
stopped = true;
|
||||
|
||||
// Cancel any rumble effects
|
||||
rumble((short)0, (short)0);
|
||||
|
||||
// Stop the input thread
|
||||
if (inputThread != null) {
|
||||
inputThread.interrupt();
|
||||
|
||||
@@ -6,6 +6,6 @@ public interface UsbDriverListener {
|
||||
float rightStickX, float rightStickY,
|
||||
float leftTrigger, float rightTrigger);
|
||||
|
||||
void deviceRemoved(int controllerId);
|
||||
void deviceAdded(int controllerId);
|
||||
void deviceRemoved(AbstractController controller);
|
||||
void deviceAdded(AbstractController controller);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ 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;
|
||||
|
||||
@@ -25,6 +28,7 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
||||
"com.limelight.USB_PERMISSION";
|
||||
|
||||
private UsbManager usbManager;
|
||||
private PreferenceConfiguration prefConfig;
|
||||
|
||||
private final UsbEventReceiver receiver = new UsbEventReceiver();
|
||||
private final UsbDriverBinder binder = new UsbDriverBinder();
|
||||
@@ -43,26 +47,21 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deviceRemoved(int controllerId) {
|
||||
public void deviceRemoved(AbstractController controller) {
|
||||
// Remove the the controller from our list (if not removed already)
|
||||
for (AbstractController controller : controllers) {
|
||||
if (controller.getControllerId() == controllerId) {
|
||||
controllers.remove(controller);
|
||||
break;
|
||||
}
|
||||
}
|
||||
controllers.remove(controller);
|
||||
|
||||
// Call through to the client's listener
|
||||
if (listener != null) {
|
||||
listener.deviceRemoved(controllerId);
|
||||
listener.deviceRemoved(controller);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deviceAdded(int controllerId) {
|
||||
public void deviceAdded(AbstractController controller) {
|
||||
// Call through to the client's listener
|
||||
if (listener != null) {
|
||||
listener.deviceAdded(controllerId);
|
||||
listener.deviceAdded(controller);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +108,7 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
||||
// Report all controllerMap that already exist
|
||||
if (listener != null) {
|
||||
for (AbstractController controller : controllers) {
|
||||
listener.deviceAdded(controller.getControllerId());
|
||||
listener.deviceAdded(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,11 +116,21 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
||||
|
||||
private void handleUsbDeviceState(UsbDevice device) {
|
||||
// Are we able to operate it?
|
||||
if (shouldClaimDevice(device)) {
|
||||
if (shouldClaimDevice(device, prefConfig.bindAllUsb)) {
|
||||
// Do we have permission yet?
|
||||
if (!usbManager.hasPermission(device)) {
|
||||
// Let's ask for permission
|
||||
usbManager.requestPermission(device, PendingIntent.getBroadcast(UsbDriverService.this, 0, new Intent(ACTION_USB_PERMISSION), 0));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -157,7 +166,7 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRecognizedInputDevice(UsbDevice device) {
|
||||
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.
|
||||
@@ -182,16 +191,39 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldClaimDevice(UsbDevice device) {
|
||||
// We always bind to XB1 controllers but only bind to XB360 controllers
|
||||
// if we know the kernel isn't already driving this device.
|
||||
return XboxOneController.canClaimDevice(device) ||
|
||||
(!isRecognizedInputDevice(device) && Xbox360Controller.canClaimDevice(device));
|
||||
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();
|
||||
@@ -201,7 +233,7 @@ public class UsbDriverService extends Service implements UsbDriverListener {
|
||||
|
||||
// Enumerate existing devices
|
||||
for (UsbDevice dev : usbManager.getDeviceList().values()) {
|
||||
if (shouldClaimDevice(dev)) {
|
||||
if (shouldClaimDevice(dev, prefConfig.bindAllUsb)) {
|
||||
// Start the process of claiming this device
|
||||
handleUsbDeviceState(dev);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public class Xbox360Controller extends AbstractXboxController {
|
||||
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
|
||||
@@ -23,6 +24,7 @@ public class Xbox360Controller extends AbstractXboxController {
|
||||
0x07ff, // Mad Catz
|
||||
0x0e6f, // Unknown
|
||||
0x0f0d, // Hori
|
||||
0x1038, // SteelSeries
|
||||
0x11c9, // Nacon
|
||||
0x12ab, // Unknown
|
||||
0x1430, // RedOctane
|
||||
@@ -137,4 +139,17 @@ public class Xbox360Controller extends AbstractXboxController {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.limelight.LimeLog;
|
||||
import com.limelight.nvstream.input.ControllerPacket;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class XboxOneController extends AbstractXboxController {
|
||||
|
||||
@@ -23,8 +24,34 @@ public class XboxOneController extends AbstractXboxController {
|
||||
0x24c6, // PowerA
|
||||
};
|
||||
|
||||
// FIXME: odata_serial
|
||||
private static final byte[] XB1_INIT_DATA = {0x05, 0x20, 0x00, 0x01, 0x00};
|
||||
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);
|
||||
@@ -111,13 +138,55 @@ public class XboxOneController extends AbstractXboxController {
|
||||
|
||||
@Override
|
||||
protected boolean doInit() {
|
||||
// Send the initialization packet
|
||||
int res = connection.bulkTransfer(outEndpt, XB1_INIT_DATA, XB1_INIT_DATA.length, 3000);
|
||||
if (res != XB1_INIT_DATA.length) {
|
||||
LimeLog.warning("Initialization transfer failed: "+res);
|
||||
return false;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ 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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.limelight.binding.input;
|
||||
package com.limelight.binding.input.touch;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.View;
|
||||
|
||||
import com.limelight.nvstream.NvConnection;
|
||||
@@ -8,7 +9,7 @@ import com.limelight.nvstream.input.MouseButtonPacket;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
public class TouchContext {
|
||||
public class RelativeTouchContext implements TouchContext {
|
||||
private int lastTouchX = 0;
|
||||
private int lastTouchY = 0;
|
||||
private int originalTouchX = 0;
|
||||
@@ -32,8 +33,8 @@ public class TouchContext {
|
||||
private static final int TAP_TIME_THRESHOLD = 250;
|
||||
private static final int DRAG_TIME_THRESHOLD = 650;
|
||||
|
||||
public TouchContext(NvConnection conn, int actionIndex,
|
||||
int referenceWidth, int referenceHeight, View view)
|
||||
public RelativeTouchContext(NvConnection conn, int actionIndex,
|
||||
int referenceWidth, int referenceHeight, View view)
|
||||
{
|
||||
this.conn = conn;
|
||||
this.actionIndex = actionIndex;
|
||||
@@ -42,6 +43,7 @@ public class TouchContext {
|
||||
this.targetView = view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getActionIndex()
|
||||
{
|
||||
return actionIndex;
|
||||
@@ -57,7 +59,7 @@ public class TouchContext {
|
||||
|
||||
private boolean isTap()
|
||||
{
|
||||
long timeDelta = System.currentTimeMillis() - originalTouchTime;
|
||||
long timeDelta = SystemClock.uptimeMillis() - originalTouchTime;
|
||||
|
||||
return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD;
|
||||
}
|
||||
@@ -72,7 +74,8 @@ public class TouchContext {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean touchDownEvent(int eventX, int eventY)
|
||||
@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();
|
||||
@@ -80,7 +83,7 @@ public class TouchContext {
|
||||
|
||||
originalTouchX = lastTouchX = eventX;
|
||||
originalTouchY = lastTouchY = eventY;
|
||||
originalTouchTime = System.currentTimeMillis();
|
||||
originalTouchTime = SystemClock.uptimeMillis();
|
||||
cancelled = confirmedDrag = confirmedMove = false;
|
||||
distanceMoved = 0;
|
||||
|
||||
@@ -92,6 +95,7 @@ public class TouchContext {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void touchUpEvent(int eventX, int eventY)
|
||||
{
|
||||
if (cancelled) {
|
||||
@@ -128,7 +132,7 @@ public class TouchContext {
|
||||
dragTimer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
synchronized (TouchContext.this) {
|
||||
synchronized (RelativeTouchContext.this) {
|
||||
// Check if someone already set move
|
||||
if (confirmedMove) {
|
||||
return;
|
||||
@@ -179,6 +183,7 @@ public class TouchContext {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean touchMoveEvent(int eventX, int eventY)
|
||||
{
|
||||
if (eventX != lastTouchX || eventY != lastTouchY)
|
||||
@@ -223,6 +228,7 @@ public class TouchContext {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancelTouch() {
|
||||
cancelled = true;
|
||||
|
||||
@@ -235,7 +241,11 @@ public class TouchContext {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCancelled() {
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPointerCount(int 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();
|
||||
}
|
||||
@@ -164,8 +164,8 @@ public class AnalogStick extends VirtualControllerElement {
|
||||
}
|
||||
}
|
||||
|
||||
public AnalogStick(VirtualController controller, Context context) {
|
||||
super(controller, context);
|
||||
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;
|
||||
@@ -210,7 +210,7 @@ public class AnalogStick extends VirtualControllerElement {
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
// calculate new radius sizes depending
|
||||
radius_complete = getPercent(getCorrectWidth() / 2, 90);
|
||||
radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth();
|
||||
radius_dead_zone = getPercent(getCorrectWidth() / 2, 30);
|
||||
radius_analog_stick = getPercent(getCorrectWidth() / 2, 20);
|
||||
|
||||
@@ -293,12 +293,12 @@ public class AnalogStick extends VirtualControllerElement {
|
||||
movement_radius = getMovementRadius(relative_x, relative_y);
|
||||
movement_angle = getAngle(relative_x, relative_y);
|
||||
|
||||
// chop radius if out of outer circle and already pressed
|
||||
// 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)) {
|
||||
// not pressed already, so ignore event from outer circle
|
||||
if (!isPressed()) {
|
||||
return false;
|
||||
}
|
||||
movement_radius = radius_complete - radius_analog_stick;
|
||||
}
|
||||
|
||||
@@ -338,6 +338,7 @@ public class AnalogStick extends VirtualControllerElement {
|
||||
} else {
|
||||
stick_state = STICK_STATE.NO_MOVEMENT;
|
||||
notifyOnRevoke();
|
||||
|
||||
// not longer pressed reset analog stick
|
||||
notifyOnMovement(0, 0);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
|
||||
@@ -60,6 +61,7 @@ public class DigitalButton extends VirtualControllerElement {
|
||||
private TimerLongClickTimerTask longClickTimerTask = null;
|
||||
|
||||
private final Paint paint = new Paint();
|
||||
private final RectF rect = new RectF();
|
||||
|
||||
private int layer;
|
||||
private DigitalButton movingButton = null;
|
||||
@@ -120,8 +122,8 @@ public class DigitalButton extends VirtualControllerElement {
|
||||
}
|
||||
}
|
||||
|
||||
public DigitalButton(VirtualController controller, int layer, Context context) {
|
||||
super(controller, context);
|
||||
public DigitalButton(VirtualController controller, int elementId, int layer, Context context) {
|
||||
super(controller, context, elementId);
|
||||
this.layer = layer;
|
||||
}
|
||||
|
||||
@@ -144,14 +146,18 @@ public class DigitalButton extends VirtualControllerElement {
|
||||
// set transparent background
|
||||
canvas.drawColor(Color.TRANSPARENT);
|
||||
|
||||
paint.setTextSize(getPercent(getWidth(), 30));
|
||||
paint.setTextSize(getPercent(getWidth(), 25));
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
paint.setStrokeWidth(getDefaultStrokeWidth());
|
||||
|
||||
paint.setColor(isPressed() ? pressedColor : getDefaultColor());
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
canvas.drawRect(paint.getStrokeWidth(), paint.getStrokeWidth(),
|
||||
getWidth() - paint.getStrokeWidth(), getHeight() - paint.getStrokeWidth(), paint);
|
||||
|
||||
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);
|
||||
|
||||
@@ -27,7 +27,7 @@ public class DigitalPad extends VirtualControllerElement {
|
||||
private final Paint paint = new Paint();
|
||||
|
||||
public DigitalPad(VirtualController controller, Context context) {
|
||||
super(controller, context);
|
||||
super(controller, context, EID_DPAD);
|
||||
}
|
||||
|
||||
public void addDigitalPadListener(DigitalPadListener listener) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.limelight.nvstream.input.ControllerPacket;
|
||||
|
||||
public class LeftAnalogStick extends AnalogStick {
|
||||
public LeftAnalogStick(final VirtualController controller, final Context context) {
|
||||
super(controller, context);
|
||||
super(controller, context, EID_LS);
|
||||
|
||||
addAnalogStickListener(new AnalogStick.AnalogStickListener() {
|
||||
@Override
|
||||
|
||||
@@ -8,7 +8,7 @@ import android.content.Context;
|
||||
|
||||
public class LeftTrigger extends DigitalButton {
|
||||
public LeftTrigger(final VirtualController controller, final int layer, final Context context) {
|
||||
super(controller, layer, context);
|
||||
super(controller, EID_LT, layer, context);
|
||||
addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
|
||||
@Override
|
||||
public void onClick() {
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.limelight.nvstream.input.ControllerPacket;
|
||||
|
||||
public class RightAnalogStick extends AnalogStick {
|
||||
public RightAnalogStick(final VirtualController controller, final Context context) {
|
||||
super(controller, context);
|
||||
super(controller, context, EID_RS);
|
||||
|
||||
addAnalogStickListener(new AnalogStick.AnalogStickListener() {
|
||||
@Override
|
||||
|
||||
@@ -8,7 +8,7 @@ import android.content.Context;
|
||||
|
||||
public class RightTrigger extends DigitalButton {
|
||||
public RightTrigger(final VirtualController controller, final int layer, final Context context) {
|
||||
super(controller, layer, context);
|
||||
super(controller, EID_RT, layer, context);
|
||||
addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
|
||||
@Override
|
||||
public void onClick() {
|
||||
|
||||
@@ -13,13 +13,16 @@ 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 class ControllerInputContext {
|
||||
public static class ControllerInputContext {
|
||||
public short inputMap = 0x0000;
|
||||
public byte leftTrigger = 0x00;
|
||||
public byte rightTrigger = 0x00;
|
||||
@@ -31,27 +34,29 @@ public class VirtualController {
|
||||
|
||||
public enum ControllerMode {
|
||||
Active,
|
||||
Configuration
|
||||
MoveButtons,
|
||||
ResizeButtons
|
||||
}
|
||||
|
||||
private static final boolean _PRINT_DEBUG_INFORMATION = false;
|
||||
|
||||
private NvConnection connection = null;
|
||||
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 RelativeLayout.LayoutParams layoutParamsButtonConfigure = null;
|
||||
private Button buttonConfigure = null;
|
||||
|
||||
private List<VirtualControllerElement> elements = new ArrayList<>();
|
||||
|
||||
public VirtualController(final NvConnection conn, FrameLayout layout, final Context context) {
|
||||
this.connection = conn;
|
||||
public VirtualController(final ControllerHandler controllerHandler, FrameLayout layout, final Context context) {
|
||||
this.controllerHandler = controllerHandler;
|
||||
this.frame_layout = layout;
|
||||
this.context = context;
|
||||
|
||||
@@ -60,18 +65,24 @@ public class VirtualController {
|
||||
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.Configuration) {
|
||||
currentMode = ControllerMode.Active;
|
||||
message = "Exiting configuration mode";
|
||||
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.Configuration;
|
||||
message = "Entering configuration mode";
|
||||
currentMode = ControllerMode.Active;
|
||||
VirtualControllerConfigurationLoader.saveProfile(VirtualController.this, context);
|
||||
message = "Exiting configuration mode";
|
||||
}
|
||||
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
|
||||
@@ -83,6 +94,28 @@ public class VirtualController {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -92,6 +125,13 @@ public class VirtualController {
|
||||
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);
|
||||
@@ -116,11 +156,19 @@ public class VirtualController {
|
||||
|
||||
DisplayMetrics screen = context.getResources().getDisplayMetrics();
|
||||
|
||||
int buttonSize = (int)(screen.heightPixels*0.05f);
|
||||
layoutParamsButtonConfigure = new RelativeLayout.LayoutParams(buttonSize, buttonSize);
|
||||
relative_layout.addView(buttonConfigure, layoutParamsButtonConfigure);
|
||||
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() {
|
||||
@@ -131,32 +179,23 @@ public class VirtualController {
|
||||
return inputContext;
|
||||
}
|
||||
|
||||
public void sendControllerInputContext() {
|
||||
sendControllerInputPacket();
|
||||
}
|
||||
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);
|
||||
|
||||
private void sendControllerInputPacket() {
|
||||
try {
|
||||
_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);
|
||||
_DBG("RIGHT STICK X: " + inputContext.rightStickX + " Y: " + inputContext.rightStickY);
|
||||
|
||||
if (connection != null) {
|
||||
connection.sendControllerInput(
|
||||
inputContext.inputMap,
|
||||
inputContext.leftTrigger,
|
||||
inputContext.rightTrigger,
|
||||
inputContext.leftStickX,
|
||||
inputContext.leftStickY,
|
||||
inputContext.rightStickX,
|
||||
inputContext.rightStickY
|
||||
);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
if (controllerHandler != null) {
|
||||
controllerHandler.reportOscState(
|
||||
inputContext.inputMap,
|
||||
inputContext.leftStickX,
|
||||
inputContext.leftStickY,
|
||||
inputContext.rightStickX,
|
||||
inputContext.rightStickY,
|
||||
inputContext.leftTrigger,
|
||||
inputContext.rightTrigger
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
|
||||
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 {
|
||||
private static final String PROFILE_PATH = "profiles";
|
||||
public static final String OSC_PREFERENCE = "OSC";
|
||||
|
||||
private static int getPercent(
|
||||
int percent,
|
||||
@@ -19,6 +24,11 @@ public class VirtualControllerConfigurationLoader {
|
||||
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) {
|
||||
@@ -59,6 +69,7 @@ public class VirtualControllerConfigurationLoader {
|
||||
}
|
||||
|
||||
private static DigitalButton createDigitalButton(
|
||||
final int elementId,
|
||||
final int keyShort,
|
||||
final int keyLong,
|
||||
final int layer,
|
||||
@@ -66,7 +77,7 @@ public class VirtualControllerConfigurationLoader {
|
||||
final int icon,
|
||||
final VirtualController controller,
|
||||
final Context context) {
|
||||
DigitalButton button = new DigitalButton(controller, layer, context);
|
||||
DigitalButton button = new DigitalButton(controller, elementId, layer, context);
|
||||
button.setText(text);
|
||||
button.setIcon(icon);
|
||||
|
||||
@@ -139,168 +150,221 @@ public class VirtualControllerConfigurationLoader {
|
||||
return new RightAnalogStick(controller, context);
|
||||
}
|
||||
|
||||
private static final int BUTTON_BASE_X = 65;
|
||||
private static final int BUTTON_BASE_Y = 5;
|
||||
private static final int BUTTON_WIDTH = getPercent(30, 33);
|
||||
private static final int BUTTON_HEIGHT = getPercent(40, 33);
|
||||
|
||||
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),
|
||||
getPercent(5, screen.widthPixels),
|
||||
getPercent(BUTTON_BASE_Y, screen.heightPixels),
|
||||
getPercent(30, screen.widthPixels),
|
||||
getPercent(40, screen.heightPixels)
|
||||
screenScale(DPAD_BASE_X, height),
|
||||
screenScale(DPAD_BASE_Y, height),
|
||||
screenScale(DPAD_SIZE, height),
|
||||
screenScale(DPAD_SIZE, height)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
ControllerPacket.A_FLAG, 0, 1, "A", -1, controller, context),
|
||||
getPercent(BUTTON_BASE_X, screen.widthPixels) + getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_BASE_Y, screen.heightPixels) + 2 * getPercent(BUTTON_HEIGHT, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
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(
|
||||
ControllerPacket.B_FLAG, 0, 1, "B", -1, controller, context),
|
||||
getPercent(BUTTON_BASE_X, screen.widthPixels) + 2 * getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_BASE_Y, screen.heightPixels) + getPercent(BUTTON_HEIGHT, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
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(
|
||||
ControllerPacket.X_FLAG, 0, 1, "X", -1, controller, context),
|
||||
getPercent(BUTTON_BASE_X, screen.widthPixels),
|
||||
getPercent(BUTTON_BASE_Y, screen.heightPixels) + getPercent(BUTTON_HEIGHT, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
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(
|
||||
ControllerPacket.Y_FLAG, 0, 1, "Y", -1, controller, context),
|
||||
getPercent(BUTTON_BASE_X, screen.widthPixels) + getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_BASE_Y, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
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(
|
||||
0, "LT", -1, controller, context),
|
||||
getPercent(BUTTON_BASE_X, screen.widthPixels),
|
||||
getPercent(BUTTON_BASE_Y, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
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(
|
||||
0, "RT", -1, controller, context),
|
||||
getPercent(BUTTON_BASE_X, screen.widthPixels) + 2 * getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_BASE_Y, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
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),
|
||||
getPercent(BUTTON_BASE_X, screen.widthPixels),
|
||||
getPercent(BUTTON_BASE_Y, screen.heightPixels) + 2 * getPercent(BUTTON_HEIGHT, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
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),
|
||||
getPercent(BUTTON_BASE_X, screen.widthPixels) + 2 * getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_BASE_Y, screen.heightPixels) + 2 * getPercent(BUTTON_HEIGHT, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
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),
|
||||
getPercent(5, screen.widthPixels),
|
||||
getPercent(50, screen.heightPixels),
|
||||
getPercent(40, screen.widthPixels),
|
||||
getPercent(50, screen.heightPixels)
|
||||
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),
|
||||
getPercent(55, screen.widthPixels),
|
||||
getPercent(50, screen.heightPixels),
|
||||
getPercent(40, screen.widthPixels),
|
||||
getPercent(50, screen.heightPixels)
|
||||
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),
|
||||
getPercent(40, screen.widthPixels),
|
||||
getPercent(90, screen.heightPixels),
|
||||
getPercent(10, screen.widthPixels),
|
||||
getPercent(10, screen.heightPixels)
|
||||
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),
|
||||
getPercent(40, screen.widthPixels) + getPercent(10, screen.widthPixels),
|
||||
getPercent(90, screen.heightPixels),
|
||||
getPercent(10, screen.widthPixels),
|
||||
getPercent(10, screen.heightPixels)
|
||||
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.addElement(createDigitalButton(
|
||||
ControllerPacket.LS_CLK_FLAG, 0, 1, "L3", -1, controller, context),
|
||||
getPercent(2, screen.widthPixels),
|
||||
getPercent(80, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
);
|
||||
|
||||
controller.addElement(createDigitalButton(
|
||||
ControllerPacket.RS_CLK_FLAG, 0, 1, "R3", -1, controller, context),
|
||||
getPercent(89, screen.widthPixels),
|
||||
getPercent(80, screen.heightPixels),
|
||||
getPercent(BUTTON_WIDTH, screen.widthPixels),
|
||||
getPercent(BUTTON_HEIGHT, screen.heightPixels)
|
||||
);
|
||||
controller.setOpacity(config.oscOpacity);
|
||||
}
|
||||
|
||||
/*
|
||||
NOT IMPLEMENTED YET,
|
||||
this should later be used to store and load a profile for the virtual controller
|
||||
public static void saveProfile(final String name,
|
||||
final VirtualController controller,
|
||||
public static void saveProfile(final VirtualController controller,
|
||||
final Context context) {
|
||||
SharedPreferences.Editor prefEditor = context.getSharedPreferences(OSC_PREFERENCE, Activity.MODE_PRIVATE).edit();
|
||||
|
||||
SharedPreferences preferences = context.getSharedPreferences(PROFILE_PATH + "/" +
|
||||
name, Activity.MODE_PRIVATE);
|
||||
|
||||
JSONArray elementConfigurations = new JSONArray();
|
||||
for (VirtualControllerElement element : controller.getElements()) {
|
||||
JSONObject elementConfiguration = new JSONObject();
|
||||
String prefKey = ""+element.elementId;
|
||||
try {
|
||||
elementConfiguration.put("TYPE", element.getClass().getName());
|
||||
elementConfiguration.put("CONFIGURATION", element.getConfiguration());
|
||||
elementConfigurations.put(elementConfiguration);
|
||||
} catch (Exception e) {
|
||||
prefEditor.putString(prefKey, element.getConfiguration().toString());
|
||||
} catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
SharedPreferences.Editor editor= preferences.edit();
|
||||
editor.putString("ELEMENTS", elementConfigurations.toString());
|
||||
prefEditor.apply();
|
||||
}
|
||||
|
||||
public static void loadFromPreferences(final VirtualController controller) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,37 @@ 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 configNormalColor = 0xF0FF0000;
|
||||
private int configMoveColor = 0xF0FF0000;
|
||||
private int configResizeColor = 0xF0FF00FF;
|
||||
private int configSelectedColor = 0xF000FF00;
|
||||
|
||||
protected int startSize_x;
|
||||
@@ -40,10 +61,11 @@ public abstract class VirtualControllerElement extends View {
|
||||
|
||||
private Mode currentMode = Mode.Normal;
|
||||
|
||||
protected VirtualControllerElement(VirtualController controller, Context context) {
|
||||
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) {
|
||||
@@ -91,34 +113,34 @@ public abstract class VirtualControllerElement extends View {
|
||||
|
||||
/*
|
||||
protected void actionShowNormalColorChooser() {
|
||||
AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() {
|
||||
@Override
|
||||
public void onCancel(AmbilWarnaDialog dialog)
|
||||
{}
|
||||
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();
|
||||
}
|
||||
@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) {
|
||||
}
|
||||
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();
|
||||
}
|
||||
@Override
|
||||
public void onOk(AmbilWarnaDialog dialog, int color) {
|
||||
pressedColor = color;
|
||||
invalidate();
|
||||
}
|
||||
});
|
||||
colorDialog.show();
|
||||
}
|
||||
*/
|
||||
|
||||
protected void actionEnableMove() {
|
||||
@@ -135,8 +157,12 @@ public abstract class VirtualControllerElement extends View {
|
||||
}
|
||||
|
||||
protected int getDefaultColor() {
|
||||
return (virtualController.getControllerMode() == VirtualController.ControllerMode.Configuration) ?
|
||||
configNormalColor : normalColor;
|
||||
if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons)
|
||||
return configMoveColor;
|
||||
else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)
|
||||
return configResizeColor;
|
||||
else
|
||||
return normalColor;
|
||||
}
|
||||
|
||||
protected int getDefaultStrokeWidth() {
|
||||
@@ -145,58 +171,54 @@ public abstract class VirtualControllerElement extends View {
|
||||
}
|
||||
|
||||
protected void showConfigurationDialog() {
|
||||
try {
|
||||
AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext());
|
||||
AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext());
|
||||
|
||||
alertBuilder.setTitle("Configuration");
|
||||
alertBuilder.setTitle("Configuration");
|
||||
|
||||
CharSequence functions[] = new CharSequence[]{
|
||||
"Move",
|
||||
"Resize",
|
||||
/*election
|
||||
"Set n
|
||||
Disable color sormal color",
|
||||
"Set pressed color",
|
||||
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;
|
||||
}
|
||||
*/
|
||||
"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();
|
||||
default: { // cancel
|
||||
actionCancel();
|
||||
break;
|
||||
}
|
||||
case 3: { // set pressed color
|
||||
actionShowPressedColorChooser();
|
||||
break;
|
||||
}
|
||||
*/
|
||||
default: { // cancel
|
||||
actionCancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
AlertDialog alert = alertBuilder.create();
|
||||
// show menu
|
||||
alert.show();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
AlertDialog alert = alertBuilder.create();
|
||||
// show menu
|
||||
alert.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -213,7 +235,10 @@ public abstract class VirtualControllerElement extends View {
|
||||
startSize_x = getWidth();
|
||||
startSize_y = getHeight();
|
||||
|
||||
actionEnableMove();
|
||||
if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons)
|
||||
actionEnableMove();
|
||||
else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)
|
||||
actionEnableResize();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -270,6 +295,15 @@ public abstract class VirtualControllerElement extends View {
|
||||
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;
|
||||
}
|
||||
@@ -278,13 +312,28 @@ public abstract class VirtualControllerElement extends View {
|
||||
return getWidth() > getHeight() ? getHeight() : getWidth();
|
||||
}
|
||||
|
||||
/**
|
||||
public JSONObject getConfiguration () {
|
||||
JSONObject configuration = new JSONObject();
|
||||
return configuration;
|
||||
}
|
||||
|
||||
public void loadConfiguration (JSONObject configuration) {
|
||||
}
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.limelight.binding.video;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.jcodec.codecs.h264.H264Utils;
|
||||
@@ -9,10 +8,12 @@ import org.jcodec.codecs.h264.io.model.SeqParameterSet;
|
||||
import org.jcodec.codecs.h264.io.model.VUIParameters;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.R;
|
||||
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
|
||||
import com.limelight.nvstream.jni.MoonBridge;
|
||||
import com.limelight.preferences.PreferenceConfiguration;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaFormat;
|
||||
@@ -39,11 +40,12 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
private boolean submittedCsd;
|
||||
private boolean submitCsdNextCall;
|
||||
|
||||
private Context context;
|
||||
private MediaCodec videoDecoder;
|
||||
private Thread rendererThread;
|
||||
private Thread[] spinnerThreads;
|
||||
private boolean needsSpsBitstreamFixup, isExynos4;
|
||||
private boolean adaptivePlayback, directSubmit;
|
||||
private boolean lowLatency;
|
||||
private boolean constrainedHighProfile;
|
||||
private boolean refFrameInvalidationAvc, refFrameInvalidationHevc;
|
||||
private boolean refFrameInvalidationActive;
|
||||
@@ -56,6 +58,12 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
private int consecutiveCrashCount;
|
||||
private String glRenderer;
|
||||
private boolean foreground = true;
|
||||
private boolean legacyFrameDropRendering = false;
|
||||
private PerfOverlayListener perfListener;
|
||||
|
||||
private MediaFormat inputFormat;
|
||||
private MediaFormat outputFormat;
|
||||
private MediaFormat configuredFormat;
|
||||
|
||||
private boolean needsBaselineSpsHack;
|
||||
private SeqParameterSet savedSps;
|
||||
@@ -64,13 +72,11 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
private long initialExceptionTimestamp;
|
||||
private static final int EXCEPTION_REPORT_DELAY_MS = 3000;
|
||||
|
||||
private VideoStats activeWindowVideoStats;
|
||||
private VideoStats lastWindowVideoStats;
|
||||
private VideoStats globalVideoStats;
|
||||
|
||||
private long lastTimestampUs;
|
||||
private long decoderTimeMs;
|
||||
private long totalTimeMs;
|
||||
private int totalFramesReceived;
|
||||
private int totalFramesRendered;
|
||||
private int frameLossEvents;
|
||||
private int framesLost;
|
||||
private int lastFrameNumber;
|
||||
private int refreshRate;
|
||||
private PreferenceConfiguration prefs;
|
||||
@@ -100,10 +106,11 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
// for even required levels of HEVC.
|
||||
MediaCodecInfo decoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/hevc", -1);
|
||||
if (decoderInfo != null) {
|
||||
if (!MediaCodecHelper.decoderIsWhitelistedForHevc(decoderInfo.getName(), meteredNetwork, requestedHdr)) {
|
||||
if (!MediaCodecHelper.decoderIsWhitelistedForHevc(decoderInfo.getName(), meteredNetwork)) {
|
||||
LimeLog.info("Found HEVC decoder, but it's not whitelisted - "+decoderInfo.getName());
|
||||
|
||||
if (prefs.videoFormat == PreferenceConfiguration.FORCE_H265_ON) {
|
||||
// HDR implies HEVC forced on, since HEVCMain10HDR10 is required for HDR
|
||||
if (prefs.videoFormat == PreferenceConfiguration.FORCE_H265_ON || requestedHdr) {
|
||||
LimeLog.info("Forcing H265 enabled despite non-whitelisted decoder");
|
||||
}
|
||||
else {
|
||||
@@ -119,24 +126,22 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
this.renderTarget = renderTarget;
|
||||
}
|
||||
|
||||
public MediaCodecDecoderRenderer(PreferenceConfiguration prefs,
|
||||
public MediaCodecDecoderRenderer(Context context, PreferenceConfiguration prefs,
|
||||
CrashListener crashListener, int consecutiveCrashCount,
|
||||
boolean meteredData, boolean requestedHdr,
|
||||
String glRenderer) {
|
||||
String glRenderer, PerfOverlayListener perfListener) {
|
||||
//dumpDecoders();
|
||||
|
||||
this.context = context;
|
||||
this.prefs = prefs;
|
||||
this.crashListener = crashListener;
|
||||
this.consecutiveCrashCount = consecutiveCrashCount;
|
||||
this.glRenderer = glRenderer;
|
||||
this.perfListener = perfListener;
|
||||
|
||||
// Disable spinner threads in battery saver mode or 4K
|
||||
if (prefs.batterySaver || prefs.height >= 2160) {
|
||||
spinnerThreads = new Thread[0];
|
||||
}
|
||||
else {
|
||||
spinnerThreads = new Thread[Runtime.getRuntime().availableProcessors()];
|
||||
}
|
||||
this.activeWindowVideoStats = new VideoStats();
|
||||
this.lastWindowVideoStats = new VideoStats();
|
||||
this.globalVideoStats = new VideoStats();
|
||||
|
||||
avcDecoder = findAvcDecoder();
|
||||
if (avcDecoder != null) {
|
||||
@@ -161,10 +166,14 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
// shared between AVC and HEVC decoders on the same device.
|
||||
if (avcDecoder != null) {
|
||||
directSubmit = MediaCodecHelper.decoderCanDirectSubmit(avcDecoder.getName());
|
||||
adaptivePlayback = MediaCodecHelper.decoderSupportsAdaptivePlayback(avcDecoder);
|
||||
refFrameInvalidationAvc = MediaCodecHelper.decoderSupportsRefFrameInvalidationAvc(avcDecoder.getName(), prefs.height);
|
||||
refFrameInvalidationHevc = MediaCodecHelper.decoderSupportsRefFrameInvalidationHevc(avcDecoder.getName());
|
||||
|
||||
if (consecutiveCrashCount % 2 == 1) {
|
||||
refFrameInvalidationAvc = refFrameInvalidationHevc = false;
|
||||
LimeLog.warning("Disabling RFI due to previous crash");
|
||||
}
|
||||
|
||||
if (directSubmit) {
|
||||
LimeLog.info("Decoder "+avcDecoder.getName()+" will use direct submit");
|
||||
}
|
||||
@@ -185,6 +194,15 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
return avcDecoder != null;
|
||||
}
|
||||
|
||||
public boolean isBlacklistedForFrameRate(int frameRate) {
|
||||
return avcDecoder != null && MediaCodecHelper.decoderBlacklistedForFrameRate(avcDecoder.getName(), frameRate);
|
||||
}
|
||||
|
||||
public void enableLegacyFrameDropRendering() {
|
||||
LimeLog.info("Legacy frame drop rendering enabled");
|
||||
legacyFrameDropRendering = true;
|
||||
}
|
||||
|
||||
public boolean isHevcMain10Hdr10Supported() {
|
||||
if (hevcDecoder == null) {
|
||||
return false;
|
||||
@@ -201,15 +219,10 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
}
|
||||
|
||||
public void notifyVideoForeground() {
|
||||
startSpinnerThreads();
|
||||
foreground = true;
|
||||
}
|
||||
|
||||
public void notifyVideoBackground() {
|
||||
// Signal the spinner threads to stop but
|
||||
// don't wait for them to terminate to avoid
|
||||
// delaying the state transition
|
||||
signalSpinnerStop();
|
||||
foreground = false;
|
||||
}
|
||||
|
||||
@@ -255,6 +268,9 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
}
|
||||
|
||||
refFrameInvalidationActive = refFrameInvalidationAvc;
|
||||
|
||||
lowLatency = MediaCodecHelper.decoderSupportsLowLatency(avcDecoder, mimeType);
|
||||
adaptivePlayback = MediaCodecHelper.decoderSupportsAdaptivePlayback(avcDecoder, mimeType);
|
||||
}
|
||||
else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) {
|
||||
mimeType = "video/hevc";
|
||||
@@ -266,6 +282,9 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
}
|
||||
|
||||
refFrameInvalidationActive = refFrameInvalidationHevc;
|
||||
|
||||
lowLatency = MediaCodecHelper.decoderSupportsLowLatency(hevcDecoder, mimeType);
|
||||
adaptivePlayback = MediaCodecHelper.decoderSupportsAdaptivePlayback(hevcDecoder, mimeType);
|
||||
}
|
||||
else {
|
||||
// Unknown format
|
||||
@@ -284,6 +303,14 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
|
||||
MediaFormat videoFormat = MediaFormat.createVideoFormat(mimeType, width, height);
|
||||
|
||||
// Avoid setting KEY_FRAME_RATE on Lollipop and earlier to reduce compatibility risk
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// We use prefs.fps instead of redrawRate here because the low latency hack in Game.java
|
||||
// may leave us with an odd redrawRate value like 59 or 49 which might cause the decoder
|
||||
// to puke. To be safe, we'll use the unmodified value.
|
||||
videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, prefs.fps);
|
||||
}
|
||||
|
||||
// Adaptive playback can also be enabled by the whitelist on pre-KitKat devices
|
||||
// so we don't fill these pre-KitKat
|
||||
if (adaptivePlayback && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
@@ -291,16 +318,39 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
videoFormat.setInteger(MediaFormat.KEY_MAX_HEIGHT, height);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// 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.
|
||||
videoFormat.setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && lowLatency) {
|
||||
videoFormat.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
|
||||
}
|
||||
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// Set the Qualcomm vendor low latency extension if the Android R option is unavailable
|
||||
if (MediaCodecHelper.decoderSupportsQcomVendorLowLatency(selectedDecoderName)) {
|
||||
// MediaCodec supports vendor-defined format keys using the "vendor.<extension name>.<parameter name>" syntax.
|
||||
// These allow access to functionality that is not exposed through documented MediaFormat.KEY_* values.
|
||||
// https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/common/inc/vidc_vendor_extensions.h;l=67
|
||||
//
|
||||
// Examples of Qualcomm's vendor extensions for Snapdragon 845:
|
||||
// https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/vdec/src/omx_vdec_extensions.hpp
|
||||
// https://cs.android.com/android/_/android/platform/hardware/qcom/sm8150/media/+/0621ceb1c1b19564999db8293574a0e12952ff6c
|
||||
videoFormat.setInteger("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||
}
|
||||
|
||||
if (MediaCodecHelper.decoderSupportsMaxOperatingRate(selectedDecoderName)) {
|
||||
videoFormat.setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE);
|
||||
}
|
||||
}
|
||||
|
||||
configuredFormat = videoFormat;
|
||||
LimeLog.info("Configuring with format: "+configuredFormat);
|
||||
|
||||
try {
|
||||
videoDecoder.configure(videoFormat, renderTarget.getSurface(), null, 0);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
// This will contain the actual accepted input format attributes
|
||||
inputFormat = videoDecoder.getInputFormat();
|
||||
LimeLog.info("Input format: "+inputFormat);
|
||||
}
|
||||
|
||||
videoDecoder.setVideoScalingMode(MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT);
|
||||
|
||||
if (USE_FRAME_RENDER_TIME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@@ -310,7 +360,7 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
long delta = (renderTimeNanos / 1000000L) - (presentationTimeUs / 1000);
|
||||
if (delta >= 0 && delta < 1000) {
|
||||
if (USE_FRAME_RENDER_TIME) {
|
||||
totalTimeMs += delta;
|
||||
activeWindowVideoStats.totalTimeMs += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +375,7 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
legacyInputBuffers = videoDecoder.getInputBuffers();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return -5;
|
||||
@@ -404,23 +455,30 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
}
|
||||
|
||||
// Render the last buffer
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && prefs.disableFrameDrop) {
|
||||
// Use a PTS that will cause this frame to never be dropped if frame dropping
|
||||
// is disabled
|
||||
videoDecoder.releaseOutputBuffer(lastIndex, 0);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
if (legacyFrameDropRendering) {
|
||||
// Use a PTS that will cause this frame to be dropped if another comes in within
|
||||
// the same V-sync period
|
||||
videoDecoder.releaseOutputBuffer(lastIndex, System.nanoTime());
|
||||
}
|
||||
else {
|
||||
// Use a PTS that will cause this frame to never be dropped if frame dropping
|
||||
// is disabled
|
||||
videoDecoder.releaseOutputBuffer(lastIndex, 0);
|
||||
}
|
||||
}
|
||||
else {
|
||||
videoDecoder.releaseOutputBuffer(lastIndex, true);
|
||||
}
|
||||
|
||||
totalFramesRendered++;
|
||||
activeWindowVideoStats.totalFramesRendered++;
|
||||
|
||||
// Add delta time to the totals (excluding probable outliers)
|
||||
long delta = MediaCodecHelper.getMonotonicMillis() - (presentationTimeUs / 1000);
|
||||
if (delta >= 0 && delta < 1000) {
|
||||
decoderTimeMs += delta;
|
||||
activeWindowVideoStats.decoderTimeMs += delta;
|
||||
if (!USE_FRAME_RENDER_TIME) {
|
||||
totalTimeMs += delta;
|
||||
activeWindowVideoStats.totalTimeMs += delta;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -429,7 +487,8 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
break;
|
||||
case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
|
||||
LimeLog.info("Output format changed");
|
||||
LimeLog.info("New output Format: " + videoDecoder.getOutputFormat());
|
||||
outputFormat = videoDecoder.getOutputFormat();
|
||||
LimeLog.info("New output format: " + outputFormat);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -446,73 +505,6 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
rendererThread.start();
|
||||
}
|
||||
|
||||
private void startSpinnerThread(final int i) {
|
||||
spinnerThreads[i] = new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
// This thread exists to keep the CPU at a higher DVFS state on devices
|
||||
// where the governor scales clock speed sporadically, causing dropped frames.
|
||||
//
|
||||
// Run until we notice our thread has been removed from the spinner threads
|
||||
// array. Even if we don't notice immediately, we'll notice soon enough.
|
||||
// This will also ensure we terminate even if someone has restarted spinning
|
||||
// before we realized we should stop.
|
||||
while (this == spinnerThreads[i]) {
|
||||
try {
|
||||
Thread.sleep(0, 1);
|
||||
} catch (InterruptedException e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
spinnerThreads[i].setName("Spinner-"+i);
|
||||
spinnerThreads[i].setPriority(Thread.MIN_PRIORITY);
|
||||
spinnerThreads[i].start();
|
||||
}
|
||||
|
||||
private void startSpinnerThreads() {
|
||||
LimeLog.info("Using "+spinnerThreads.length+" spinner threads");
|
||||
for (int i = 0; i < spinnerThreads.length; i++) {
|
||||
if (spinnerThreads[i] != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
startSpinnerThread(i);
|
||||
}
|
||||
}
|
||||
|
||||
private Thread[] signalSpinnerStop() {
|
||||
// Capture current running threads
|
||||
Thread[] runningThreads = Arrays.copyOf(spinnerThreads, spinnerThreads.length);
|
||||
|
||||
// Clear the spinner threads to signal their termination
|
||||
for (int i = 0; i < spinnerThreads.length; i++) {
|
||||
spinnerThreads[i] = null;
|
||||
}
|
||||
|
||||
// Interrupt the threads
|
||||
for (int i = 0; i < runningThreads.length; i++) {
|
||||
if (runningThreads[i] != null) {
|
||||
runningThreads[i].interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
return runningThreads;
|
||||
}
|
||||
|
||||
private void stopSpinnerThreads() {
|
||||
// Signal and wait for the threads to stop
|
||||
Thread[] runningThreads = signalSpinnerStop();
|
||||
for (int i = 0; i < runningThreads.length; i++) {
|
||||
if (runningThreads[i] != null) {
|
||||
try {
|
||||
runningThreads[i].join();
|
||||
} catch (InterruptedException ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int dequeueInputBuffer() {
|
||||
int index = -1;
|
||||
long startTime;
|
||||
@@ -554,7 +546,6 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
@Override
|
||||
public void start() {
|
||||
startRendererThread();
|
||||
startSpinnerThreads();
|
||||
}
|
||||
|
||||
// !!! May be called even if setup()/start() fails !!!
|
||||
@@ -573,27 +564,10 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
// May be called already, but we'll call it now to be safe
|
||||
prepareForStop();
|
||||
|
||||
try {
|
||||
// Invalidate pending decode buffers
|
||||
videoDecoder.flush();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
// Wait for the renderer thread to shut down
|
||||
try {
|
||||
rendererThread.join();
|
||||
} catch (InterruptedException ignored) { }
|
||||
|
||||
try {
|
||||
// Stop the video decoder
|
||||
videoDecoder.stop();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
// Halt the spinner threads
|
||||
stopSpinnerThreads();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -641,15 +615,15 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
// High Profile which allows the decoder to assume there will be no B-frames and
|
||||
// reduce delay and buffering accordingly. Some devices (Marvell, Exynos 4) don't
|
||||
// like it so we only set them on devices that are confirmed to benefit from it.
|
||||
if (sps.profile_idc == 100 && constrainedHighProfile) {
|
||||
if (sps.profileIdc == 100 && constrainedHighProfile) {
|
||||
LimeLog.info("Setting constraint set flags for constrained high profile");
|
||||
sps.constraint_set_4_flag = true;
|
||||
sps.constraint_set_5_flag = true;
|
||||
sps.constraintSet4Flag = true;
|
||||
sps.constraintSet5Flag = true;
|
||||
}
|
||||
else {
|
||||
// Force the constraints unset otherwise (some may be set by default)
|
||||
sps.constraint_set_4_flag = false;
|
||||
sps.constraint_set_5_flag = false;
|
||||
sps.constraintSet4Flag = false;
|
||||
sps.constraintSet5Flag = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,17 +631,63 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
@Override
|
||||
public int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
|
||||
int frameNumber, long receiveTimeMs) {
|
||||
totalFramesReceived++;
|
||||
if (stopping) {
|
||||
// Don't bother if we're stopping
|
||||
return MoonBridge.DR_OK;
|
||||
}
|
||||
|
||||
// We can receive the same "frame" multiple times if it's an IDR frame.
|
||||
// In that case, each frame start NALU is submitted independently.
|
||||
if (frameNumber != lastFrameNumber && frameNumber != lastFrameNumber + 1) {
|
||||
framesLost += frameNumber - lastFrameNumber - 1;
|
||||
frameLossEvents++;
|
||||
if (lastFrameNumber == 0) {
|
||||
activeWindowVideoStats.measurementStartTimestamp = System.currentTimeMillis();
|
||||
} else if (frameNumber != lastFrameNumber && frameNumber != lastFrameNumber + 1) {
|
||||
// We can receive the same "frame" multiple times if it's an IDR frame.
|
||||
// In that case, each frame start NALU is submitted independently.
|
||||
activeWindowVideoStats.framesLost += frameNumber - lastFrameNumber - 1;
|
||||
activeWindowVideoStats.totalFrames += frameNumber - lastFrameNumber - 1;
|
||||
activeWindowVideoStats.frameLossEvents++;
|
||||
}
|
||||
|
||||
lastFrameNumber = frameNumber;
|
||||
|
||||
// Flip stats windows roughly every second
|
||||
if (System.currentTimeMillis() >= activeWindowVideoStats.measurementStartTimestamp + 1000) {
|
||||
if (prefs.enablePerfOverlay) {
|
||||
VideoStats lastTwo = new VideoStats();
|
||||
lastTwo.add(lastWindowVideoStats);
|
||||
lastTwo.add(activeWindowVideoStats);
|
||||
VideoStatsFps fps = lastTwo.getFps();
|
||||
String decoder;
|
||||
|
||||
if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) {
|
||||
decoder = avcDecoder.getName();
|
||||
} else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) {
|
||||
decoder = hevcDecoder.getName();
|
||||
} else {
|
||||
decoder = "(unknown)";
|
||||
}
|
||||
|
||||
float decodeTimeMs = (float)lastTwo.decoderTimeMs / lastTwo.totalFramesReceived;
|
||||
String perfText = context.getString(
|
||||
R.string.perf_overlay_text,
|
||||
initialWidth + "x" + initialHeight,
|
||||
decoder,
|
||||
fps.totalFps,
|
||||
fps.receivedFps,
|
||||
fps.renderedFps,
|
||||
(float)lastTwo.framesLost / lastTwo.totalFrames * 100,
|
||||
((float)lastTwo.totalTimeMs / lastTwo.totalFramesReceived) - decodeTimeMs,
|
||||
decodeTimeMs);
|
||||
perfListener.onPerfUpdate(perfText);
|
||||
}
|
||||
|
||||
globalVideoStats.add(activeWindowVideoStats);
|
||||
lastWindowVideoStats.copy(activeWindowVideoStats);
|
||||
activeWindowVideoStats.clear();
|
||||
activeWindowVideoStats.measurementStartTimestamp = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
activeWindowVideoStats.totalFramesReceived++;
|
||||
activeWindowVideoStats.totalFrames++;
|
||||
|
||||
int inputBufferIndex;
|
||||
ByteBuffer buf;
|
||||
|
||||
@@ -675,7 +695,7 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
|
||||
if (!FRAME_RENDER_TIME_ONLY) {
|
||||
// Count time from first packet received to decode start
|
||||
totalTimeMs += (timestampUs / 1000) - receiveTimeMs;
|
||||
activeWindowVideoStats.totalTimeMs += (timestampUs / 1000) - receiveTimeMs;
|
||||
}
|
||||
|
||||
if (timestampUs <= lastTimestampUs) {
|
||||
@@ -706,15 +726,20 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
// for known resolution combinations. Reference frame invalidation may need
|
||||
// these, so leave them be for those decoders.
|
||||
if (!refFrameInvalidationActive) {
|
||||
if (initialWidth == 1280 && initialHeight == 720) {
|
||||
if (initialWidth <= 720 && initialHeight <= 480 && refreshRate <= 60) {
|
||||
// Max 5 buffered frames at 720x480x60
|
||||
LimeLog.info("Patching level_idc to 31");
|
||||
sps.levelIdc = 31;
|
||||
}
|
||||
else if (initialWidth <= 1280 && initialHeight <= 720 && refreshRate <= 60) {
|
||||
// Max 5 buffered frames at 1280x720x60
|
||||
LimeLog.info("Patching level_idc to 32");
|
||||
sps.level_idc = 32;
|
||||
sps.levelIdc = 32;
|
||||
}
|
||||
else if (initialWidth == 1920 && initialHeight == 1080) {
|
||||
else if (initialWidth <= 1920 && initialHeight <= 1080 && refreshRate <= 60) {
|
||||
// Max 4 buffered frames at 1920x1080x64
|
||||
LimeLog.info("Patching level_idc to 42");
|
||||
sps.level_idc = 42;
|
||||
sps.levelIdc = 42;
|
||||
}
|
||||
else {
|
||||
// Leave the profile alone (currently 5.0)
|
||||
@@ -732,16 +757,20 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
// where we've enabled reference frame invalidation.
|
||||
if (!refFrameInvalidationActive) {
|
||||
LimeLog.info("Patching num_ref_frames in SPS");
|
||||
sps.num_ref_frames = 1;
|
||||
sps.numRefFrames = 1;
|
||||
}
|
||||
|
||||
// GFE 2.5.11 changed the SPS to add additional extensions
|
||||
// Some devices don't like these so we remove them here.
|
||||
sps.vuiParams.video_signal_type_present_flag = false;
|
||||
sps.vuiParams.colour_description_present_flag = false;
|
||||
sps.vuiParams.chroma_loc_info_present_flag = false;
|
||||
// Some devices don't like these so we remove them here on old devices.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
sps.vuiParams.videoSignalTypePresentFlag = false;
|
||||
sps.vuiParams.colourDescriptionPresentFlag = false;
|
||||
sps.vuiParams.chromaLocInfoPresentFlag = false;
|
||||
}
|
||||
|
||||
if ((needsSpsBitstreamFixup || isExynos4) && !refFrameInvalidationActive) {
|
||||
// Some older devices used to choke on a bitstream restrictions, so we won't provide them
|
||||
// unless explicitly whitelisted. For newer devices, leave the bitstream restrictions present.
|
||||
if (needsSpsBitstreamFixup || isExynos4 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// The SPS that comes in the current H264 bytestream doesn't set bitstream_restriction_flag
|
||||
// or max_dec_frame_buffering which increases decoding latency on Tegra.
|
||||
|
||||
@@ -749,22 +778,22 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
if (sps.vuiParams.bitstreamRestriction == null) {
|
||||
LimeLog.info("Adding bitstream restrictions");
|
||||
sps.vuiParams.bitstreamRestriction = new VUIParameters.BitstreamRestriction();
|
||||
sps.vuiParams.bitstreamRestriction.motion_vectors_over_pic_boundaries_flag = true;
|
||||
sps.vuiParams.bitstreamRestriction.log2_max_mv_length_horizontal = 16;
|
||||
sps.vuiParams.bitstreamRestriction.log2_max_mv_length_vertical = 16;
|
||||
sps.vuiParams.bitstreamRestriction.num_reorder_frames = 0;
|
||||
sps.vuiParams.bitstreamRestriction.motionVectorsOverPicBoundariesFlag = true;
|
||||
sps.vuiParams.bitstreamRestriction.log2MaxMvLengthHorizontal = 16;
|
||||
sps.vuiParams.bitstreamRestriction.log2MaxMvLengthVertical = 16;
|
||||
sps.vuiParams.bitstreamRestriction.numReorderFrames = 0;
|
||||
}
|
||||
else {
|
||||
LimeLog.info("Patching bitstream restrictions");
|
||||
}
|
||||
|
||||
// Some devices throw errors if max_dec_frame_buffering < num_ref_frames
|
||||
sps.vuiParams.bitstreamRestriction.max_dec_frame_buffering = sps.num_ref_frames;
|
||||
// Some devices throw errors if maxDecFrameBuffering < numRefFrames
|
||||
sps.vuiParams.bitstreamRestriction.maxDecFrameBuffering = sps.numRefFrames;
|
||||
|
||||
// These values are the defaults for the fields, but they are more aggressive
|
||||
// than what GFE sends in 2.5.11, but it doesn't seem to cause picture problems.
|
||||
sps.vuiParams.bitstreamRestriction.max_bytes_per_pic_denom = 2;
|
||||
sps.vuiParams.bitstreamRestriction.max_bits_per_mb_denom = 1;
|
||||
sps.vuiParams.bitstreamRestriction.maxBytesPerPicDenom = 2;
|
||||
sps.vuiParams.bitstreamRestriction.maxBitsPerMbDenom = 1;
|
||||
|
||||
// log2_max_mv_length_horizontal and log2_max_mv_length_vertical are set to more
|
||||
// conservative values by GFE 2.5.11. We'll let those values stand.
|
||||
@@ -778,7 +807,7 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
// If we need to hack this SPS to say we're baseline, do so now
|
||||
if (needsBaselineSpsHack) {
|
||||
LimeLog.info("Hacking SPS to baseline");
|
||||
sps.profile_idc = 66;
|
||||
sps.profileIdc = 66;
|
||||
savedSps = sps;
|
||||
}
|
||||
|
||||
@@ -933,7 +962,7 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
inputBuffer.put(new byte[]{0x00, 0x00, 0x00, 0x01, 0x67});
|
||||
|
||||
// Switch the H264 profile back to high
|
||||
savedSps.profile_idc = 100;
|
||||
savedSps.profileIdc = 100;
|
||||
|
||||
// Patch the SPS constraint flags
|
||||
doProfileSpecificSpsPatching(savedSps);
|
||||
@@ -977,17 +1006,17 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
}
|
||||
|
||||
public int getAverageEndToEndLatency() {
|
||||
if (totalFramesReceived == 0) {
|
||||
if (globalVideoStats.totalFramesReceived == 0) {
|
||||
return 0;
|
||||
}
|
||||
return (int)(totalTimeMs / totalFramesReceived);
|
||||
return (int)(globalVideoStats.totalTimeMs / globalVideoStats.totalFramesReceived);
|
||||
}
|
||||
|
||||
public int getAverageDecoderLatency() {
|
||||
if (totalFramesReceived == 0) {
|
||||
if (globalVideoStats.totalFramesReceived == 0) {
|
||||
return 0;
|
||||
}
|
||||
return (int)(decoderTimeMs / totalFramesReceived);
|
||||
return (int)(globalVideoStats.decoderTimeMs / globalVideoStats.totalFramesReceived);
|
||||
}
|
||||
|
||||
static class DecoderHungException extends RuntimeException {
|
||||
@@ -1032,24 +1061,46 @@ public class MediaCodecDecoderRenderer extends VideoDecoderRenderer {
|
||||
str += "HEVC Decoder: "+((renderer.hevcDecoder != null) ? renderer.hevcDecoder.getName():"(none)")+"\n";
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.avcDecoder != null) {
|
||||
Range<Integer> avcWidthRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getSupportedWidths();
|
||||
str += "AVC supported width range: "+avcWidthRange.getLower()+" - "+avcWidthRange.getUpper()+"\n";
|
||||
str += "AVC supported width range: "+avcWidthRange+"\n";
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
try {
|
||||
Range<Double> avcFpsRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight);
|
||||
str += "AVC achievable FPS range: "+avcFpsRange+"\n";
|
||||
} catch (IllegalArgumentException e) {
|
||||
str += "AVC achievable FPS range: UNSUPPORTED!\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.hevcDecoder != null) {
|
||||
Range<Integer> hevcWidthRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getSupportedWidths();
|
||||
str += "HEVC supported width range: "+hevcWidthRange.getLower()+" - "+hevcWidthRange.getUpper()+"\n";
|
||||
str += "HEVC supported width range: "+hevcWidthRange+"\n";
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
try {
|
||||
Range<Double> hevcFpsRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight);
|
||||
str += "HEVC achievable FPS range: " + hevcFpsRange + "\n";
|
||||
} catch (IllegalArgumentException e) {
|
||||
str += "HEVC achievable FPS range: UNSUPPORTED!\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
str += "Configured format: "+renderer.configuredFormat+"\n";
|
||||
str += "Input format: "+renderer.inputFormat+"\n";
|
||||
str += "Output format: "+renderer.outputFormat+"\n";
|
||||
str += "Adaptive playback: "+renderer.adaptivePlayback+"\n";
|
||||
str += "GL Renderer: "+renderer.glRenderer+"\n";
|
||||
str += "Build fingerprint: "+Build.FINGERPRINT+"\n";
|
||||
str += "Foreground: "+renderer.foreground+"\n";
|
||||
str += "Consecutive crashes: "+renderer.consecutiveCrashCount+"\n";
|
||||
str += "RFI active: "+renderer.refFrameInvalidationActive+"\n";
|
||||
str += "Using modern SPS patching: "+(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)+"\n";
|
||||
str += "Low latency mode: "+renderer.lowLatency+"\n";
|
||||
str += "Video dimensions: "+renderer.initialWidth+"x"+renderer.initialHeight+"\n";
|
||||
str += "FPS target: "+renderer.refreshRate+"\n";
|
||||
str += "Bitrate: "+renderer.prefs.bitrate+" Mbps \n";
|
||||
str += "Bitrate: "+renderer.prefs.bitrate+" Kbps \n";
|
||||
str += "In stats: "+renderer.numVpsIn+", "+renderer.numSpsIn+", "+renderer.numPpsIn+"\n";
|
||||
str += "Total frames received: "+renderer.totalFramesReceived+"\n";
|
||||
str += "Total frames rendered: "+renderer.totalFramesRendered+"\n";
|
||||
str += "Frame losses: "+renderer.framesLost+" in "+renderer.frameLossEvents+" loss events\n";
|
||||
str += "Total frames received: "+renderer.globalVideoStats.totalFramesReceived+"\n";
|
||||
str += "Total frames rendered: "+renderer.globalVideoStats.totalFramesRendered+"\n";
|
||||
str += "Frame losses: "+renderer.globalVideoStats.framesLost+" in "+renderer.globalVideoStats.frameLossEvents+" loss events\n";
|
||||
str += "Average end-to-end client latency: "+renderer.getAverageEndToEndLatency()+"ms\n";
|
||||
str += "Average hardware decoder latency: "+renderer.getAverageDecoderLatency()+"ms\n";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
package com.limelight.binding.video;
|
||||
|
||||
public interface PerfOverlayListener {
|
||||
void onPerfUpdate(final String text);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.limelight.binding.video;
|
||||
|
||||
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 = (System.currentTimeMillis() - 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;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
package com.limelight.computers;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
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 java.util.UUID;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
|
||||
import android.content.ContentValues;
|
||||
@@ -17,15 +18,15 @@ import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteException;
|
||||
|
||||
public class ComputerDatabaseManager {
|
||||
private static final String COMPUTER_DB_NAME = "computers.db";
|
||||
private static final String COMPUTER_DB_NAME = "computers3.db";
|
||||
private static final String COMPUTER_TABLE_NAME = "Computers";
|
||||
private static final String COMPUTER_NAME_COLUMN_NAME = "ComputerName";
|
||||
private static final String COMPUTER_UUID_COLUMN_NAME = "UUID";
|
||||
private static final String LOCAL_IP_COLUMN_NAME = "LocalIp";
|
||||
private static final String REMOTE_IP_COLUMN_NAME = "RemoteIp";
|
||||
private static final String MAC_COLUMN_NAME = "Mac";
|
||||
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 String ADDRESS_PREFIX = "ADDRESS_PREFIX__";
|
||||
private static final char ADDRESS_DELIMITER = ';';
|
||||
|
||||
private SQLiteDatabase computerDb;
|
||||
|
||||
@@ -38,86 +39,98 @@ public class ComputerDatabaseManager {
|
||||
c.deleteDatabase(COMPUTER_DB_NAME);
|
||||
computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null);
|
||||
}
|
||||
initializeDb();
|
||||
initializeDb(c);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
computerDb.close();
|
||||
}
|
||||
|
||||
private void initializeDb() {
|
||||
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 NOT NULL, %s TEXT NOT NULL)",
|
||||
COMPUTER_TABLE_NAME,
|
||||
COMPUTER_NAME_COLUMN_NAME, COMPUTER_UUID_COLUMN_NAME, LOCAL_IP_COLUMN_NAME,
|
||||
REMOTE_IP_COLUMN_NAME, MAC_COLUMN_NAME));
|
||||
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(String name) {
|
||||
computerDb.delete(COMPUTER_TABLE_NAME, COMPUTER_NAME_COLUMN_NAME+"=?", new String[]{name});
|
||||
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);
|
||||
values.put(COMPUTER_UUID_COLUMN_NAME, details.uuid.toString());
|
||||
values.put(LOCAL_IP_COLUMN_NAME, ADDRESS_PREFIX+details.localAddress);
|
||||
values.put(REMOTE_IP_COLUMN_NAME, ADDRESS_PREFIX+details.remoteAddress);
|
||||
values.put(MAC_COLUMN_NAME, details.macAddress);
|
||||
|
||||
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.name = c.getString(0);
|
||||
details.uuid = c.getString(0);
|
||||
details.name = c.getString(1);
|
||||
|
||||
String uuidStr = c.getString(1);
|
||||
try {
|
||||
details.uuid = UUID.fromString(uuidStr);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// We'll delete this entry
|
||||
LimeLog.severe("DB: Corrupted UUID for "+details.name);
|
||||
}
|
||||
String[] addresses = c.getString(2).split(""+ADDRESS_DELIMITER, -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);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 local address for "+details.name);
|
||||
}
|
||||
}
|
||||
byte[] derCertData = c.getBlob(4);
|
||||
|
||||
details.macAddress = c.getString(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;
|
||||
details.reachability = ComputerDetails.Reachability.UNKNOWN;
|
||||
|
||||
return details;
|
||||
}
|
||||
@@ -126,16 +139,7 @@ public class ComputerDatabaseManager {
|
||||
Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null);
|
||||
LinkedList<ComputerDetails> computerList = new LinkedList<>();
|
||||
while (c.moveToNext()) {
|
||||
ComputerDetails details = getComputerFromCursor(c);
|
||||
|
||||
// If a field is corrupt or missing, skip the database entry
|
||||
if (details.uuid == null || details.localAddress == null || details.remoteAddress == null ||
|
||||
details.macAddress == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
computerList.add(details);
|
||||
computerList.add(getComputerFromCursor(c));
|
||||
}
|
||||
|
||||
c.close();
|
||||
@@ -143,8 +147,8 @@ public class ComputerDatabaseManager {
|
||||
return computerList;
|
||||
}
|
||||
|
||||
public ComputerDetails getComputerByName(String name) {
|
||||
Cursor c = computerDb.query(COMPUTER_TABLE_NAME, null, COMPUTER_NAME_COLUMN_NAME+"=?", new String[]{name}, null, null, null);
|
||||
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();
|
||||
@@ -154,13 +158,6 @@ public class ComputerDatabaseManager {
|
||||
ComputerDetails details = getComputerFromCursor(c);
|
||||
c.close();
|
||||
|
||||
// If a field is corrupt or missing, delete the database entry
|
||||
if (details.uuid == null || details.localAddress == null || details.remoteAddress == null ||
|
||||
details.macAddress == null) {
|
||||
deleteComputer(details.name);
|
||||
return null;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,42 @@ 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.UUID;
|
||||
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 org.xmlpull.v1.XmlPullParserException;
|
||||
@@ -35,7 +48,7 @@ public class ComputerManagerService extends Service {
|
||||
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 = 500;
|
||||
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;
|
||||
@@ -51,6 +64,7 @@ public class ComputerManagerService extends Service {
|
||||
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() {
|
||||
@@ -93,7 +107,6 @@ public class ComputerManagerService extends Service {
|
||||
}
|
||||
|
||||
details.state = ComputerDetails.State.OFFLINE;
|
||||
details.reachability = ComputerDetails.Reachability.OFFLINE;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
releaseLocalDatabaseReference();
|
||||
@@ -104,17 +117,39 @@ public class ComputerManagerService extends Service {
|
||||
|
||||
// If it's online, update our persistent state
|
||||
if (details.state == ComputerDetails.State.ONLINE) {
|
||||
if (!newPc) {
|
||||
// Check if it's in the database because it could have been
|
||||
// removed after this was issued
|
||||
if (dbManager.getComputerByName(details.name) == null) {
|
||||
// It's gone
|
||||
releaseLocalDatabaseReference();
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
dbManager.updateComputer(details);
|
||||
// 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
|
||||
@@ -132,7 +167,7 @@ public class ComputerManagerService extends Service {
|
||||
public void run() {
|
||||
|
||||
int offlineCount = 0;
|
||||
while (!isInterrupted() && pollingActive) {
|
||||
while (!isInterrupted() && pollingActive && tuple.thread == this) {
|
||||
try {
|
||||
// Only allow one request to the machine at a time
|
||||
synchronized (tuple.networkLock) {
|
||||
@@ -154,7 +189,7 @@ public class ComputerManagerService extends Service {
|
||||
}
|
||||
}
|
||||
};
|
||||
t.setName("Polling thread for " + tuple.computer.localAddress);
|
||||
t.setName("Polling thread for " + tuple.computer.name);
|
||||
return t;
|
||||
}
|
||||
|
||||
@@ -175,7 +210,6 @@ public class ComputerManagerService extends Service {
|
||||
if (System.currentTimeMillis() - tuple.lastSuccessfulPollMs > POLL_DATA_TTL_MS) {
|
||||
LimeLog.info("Timing out polled state for "+tuple.computer.name);
|
||||
tuple.computer.state = ComputerDetails.State.UNKNOWN;
|
||||
tuple.computer.reachability = ComputerDetails.Reachability.UNKNOWN;
|
||||
}
|
||||
|
||||
// Report this computer initially
|
||||
@@ -210,12 +244,12 @@ public class ComputerManagerService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean addComputerBlocking(String addr, boolean manuallyAdded) {
|
||||
return ComputerManagerService.this.addComputerBlocking(addr, manuallyAdded);
|
||||
public boolean addComputerBlocking(ComputerDetails fakeDetails) {
|
||||
return ComputerManagerService.this.addComputerBlocking(fakeDetails);
|
||||
}
|
||||
|
||||
public void removeComputer(String name) {
|
||||
ComputerManagerService.this.removeComputer(name);
|
||||
public void removeComputer(ComputerDetails computer) {
|
||||
ComputerManagerService.this.removeComputer(computer);
|
||||
}
|
||||
|
||||
public void stopPolling() {
|
||||
@@ -231,7 +265,7 @@ public class ComputerManagerService extends Service {
|
||||
return idManager.getUniqueId();
|
||||
}
|
||||
|
||||
public ComputerDetails getComputer(UUID uuid) {
|
||||
public ComputerDetails getComputer(String uuid) {
|
||||
synchronized (pollingTuples) {
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
if (uuid.equals(tuple.computer.uuid)) {
|
||||
@@ -243,7 +277,7 @@ public class ComputerManagerService extends Service {
|
||||
return null;
|
||||
}
|
||||
|
||||
public void invalidateStateForComputer(UUID uuid) {
|
||||
public void invalidateStateForComputer(String uuid) {
|
||||
synchronized (pollingTuples) {
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
if (uuid.equals(tuple.computer.uuid)) {
|
||||
@@ -251,7 +285,6 @@ public class ComputerManagerService extends Service {
|
||||
// from wiping this change out
|
||||
synchronized (tuple.networkLock) {
|
||||
tuple.computer.state = ComputerDetails.State.UNKNOWN;
|
||||
tuple.computer.reachability = ComputerDetails.Reachability.UNKNOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,12 +317,91 @@ public class ComputerManagerService extends Service {
|
||||
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
|
||||
addComputerBlocking(computer.getAddress().getHostAddress(), false);
|
||||
if (!addComputerBlocking(details)) {
|
||||
LimeLog.warning("Auto-discovered PC failed to respond: "+details);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -305,22 +417,13 @@ public class ComputerManagerService extends Service {
|
||||
};
|
||||
}
|
||||
|
||||
private void addTuple(ComputerDetails details, boolean manuallyAdded) {
|
||||
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)) {
|
||||
if (manuallyAdded) {
|
||||
// Update details anyway in case this machine has been re-added by IP
|
||||
// after not being reachable by our existing information
|
||||
tuple.computer.localAddress = details.localAddress;
|
||||
tuple.computer.remoteAddress = details.remoteAddress;
|
||||
}
|
||||
else {
|
||||
// This indicates that mDNS discovered this address, so we
|
||||
// should only apply the local address.
|
||||
tuple.computer.localAddress = details.localAddress;
|
||||
}
|
||||
// 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) {
|
||||
@@ -345,15 +448,26 @@ public class ComputerManagerService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean addComputerBlocking(String addr, boolean manuallyAdded) {
|
||||
// Setup a placeholder
|
||||
ComputerDetails fakeDetails = new ComputerDetails();
|
||||
fakeDetails.localAddress = addr;
|
||||
fakeDetails.remoteAddress = addr;
|
||||
|
||||
public boolean addComputerBlocking(ComputerDetails fakeDetails) {
|
||||
// Block while we try to fill the details
|
||||
try {
|
||||
runPoll(fakeDetails, true, 0);
|
||||
// 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;
|
||||
}
|
||||
@@ -363,7 +477,7 @@ public class ComputerManagerService extends Service {
|
||||
LimeLog.info("New PC ("+fakeDetails.name+") is UUID "+fakeDetails.uuid);
|
||||
|
||||
// Start a polling thread for this machine
|
||||
addTuple(fakeDetails, manuallyAdded);
|
||||
addTuple(fakeDetails);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
@@ -371,21 +485,22 @@ public class ComputerManagerService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
public void removeComputer(String name) {
|
||||
public void removeComputer(ComputerDetails computer) {
|
||||
if (!getLocalDatabaseReference()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove it from the database
|
||||
dbManager.deleteComputer(name);
|
||||
dbManager.deleteComputer(computer);
|
||||
|
||||
synchronized (pollingTuples) {
|
||||
// Remove the computer from the computer list
|
||||
for (PollingTuple tuple : pollingTuples) {
|
||||
if (tuple.computer.name.equals(name)) {
|
||||
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;
|
||||
@@ -418,21 +533,29 @@ public class ComputerManagerService extends Service {
|
||||
}
|
||||
|
||||
try {
|
||||
NvHTTP http = new NvHTTP(address, idManager.getUniqueId(),
|
||||
null, PlatformBinding.getCryptoProvider(ComputerManagerService.this));
|
||||
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 (details.uuid != null && newDetails.uuid != null &&
|
||||
!details.uuid.equals(newDetails.uuid)) {
|
||||
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 (Exception e) {
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -440,6 +563,11 @@ public class ComputerManagerService extends Service {
|
||||
// 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);
|
||||
@@ -468,12 +596,16 @@ public class ComputerManagerService extends Service {
|
||||
t.start();
|
||||
}
|
||||
|
||||
private ComputerDetails.Reachability fastPollPc(final String localAddress, final String remoteAddress) throws InterruptedException {
|
||||
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) {
|
||||
@@ -482,155 +614,90 @@ public class ComputerManagerService extends Service {
|
||||
}
|
||||
|
||||
if (localInfo[1]) {
|
||||
return ComputerDetails.Reachability.LOCAL;
|
||||
return localAddress;
|
||||
}
|
||||
}
|
||||
|
||||
// Now remote
|
||||
// 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 ComputerDetails.Reachability.REMOTE;
|
||||
return remoteAddress;
|
||||
}
|
||||
}
|
||||
|
||||
return ComputerDetails.Reachability.OFFLINE;
|
||||
}
|
||||
// Now global IPv6
|
||||
synchronized (ipv6Info) {
|
||||
while (!ipv6Info[0]) {
|
||||
ipv6Info.wait(500);
|
||||
}
|
||||
|
||||
private ReachabilityTuple pollForReachability(ComputerDetails details) throws InterruptedException {
|
||||
ComputerDetails polledDetails;
|
||||
ComputerDetails.Reachability reachability;
|
||||
|
||||
// If the local address is routable across the Internet,
|
||||
// always consider this PC remote to be conservative
|
||||
if (details.localAddress.equals(details.remoteAddress)) {
|
||||
reachability = ComputerDetails.Reachability.REMOTE;
|
||||
}
|
||||
else {
|
||||
// Do a TCP-level connection to the HTTP server to see if it's listening
|
||||
LimeLog.info("Starting fast poll for "+details.name+" ("+details.localAddress +", "+details.remoteAddress +")");
|
||||
reachability = fastPollPc(details.localAddress, details.remoteAddress);
|
||||
LimeLog.info("Fast poll for "+details.name+" returned "+reachability.toString());
|
||||
|
||||
// If no connection could be established to either IP address, there's nothing we can do
|
||||
if (reachability == ComputerDetails.Reachability.OFFLINE) {
|
||||
return null;
|
||||
if (ipv6Info[1]) {
|
||||
return ipv6Address;
|
||||
}
|
||||
}
|
||||
|
||||
boolean localFirst = (reachability == ComputerDetails.Reachability.LOCAL);
|
||||
|
||||
if (localFirst) {
|
||||
polledDetails = tryPollIp(details, details.localAddress);
|
||||
}
|
||||
else {
|
||||
polledDetails = tryPollIp(details, details.remoteAddress);
|
||||
}
|
||||
|
||||
String reachableAddr = null;
|
||||
if (polledDetails == null && !details.localAddress.equals(details.remoteAddress)) {
|
||||
// Failed, so let's try the fallback
|
||||
if (!localFirst) {
|
||||
polledDetails = tryPollIp(details, details.localAddress);
|
||||
}
|
||||
else {
|
||||
polledDetails = tryPollIp(details, details.remoteAddress);
|
||||
}
|
||||
|
||||
if (polledDetails != null) {
|
||||
// The fallback poll worked
|
||||
reachableAddr = !localFirst ? details.localAddress : details.remoteAddress;
|
||||
}
|
||||
}
|
||||
else if (polledDetails != null) {
|
||||
reachableAddr = localFirst ? details.localAddress : details.remoteAddress;
|
||||
}
|
||||
|
||||
if (reachableAddr == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (polledDetails.remoteAddress.equals(reachableAddr)) {
|
||||
polledDetails.reachability = ComputerDetails.Reachability.REMOTE;
|
||||
}
|
||||
else if (polledDetails.localAddress.equals(reachableAddr)) {
|
||||
polledDetails.reachability = ComputerDetails.Reachability.LOCAL;
|
||||
}
|
||||
else {
|
||||
polledDetails.reachability = ComputerDetails.Reachability.UNKNOWN;
|
||||
}
|
||||
|
||||
return new ReachabilityTuple(polledDetails, reachableAddr);
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean pollComputer(ComputerDetails details) throws InterruptedException {
|
||||
ReachabilityTuple initialReachTuple = pollForReachability(details);
|
||||
if (initialReachTuple == null) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (initialReachTuple.computer.reachability == ComputerDetails.Reachability.UNKNOWN) {
|
||||
// Neither IP address reported in the serverinfo response was the one we used.
|
||||
// Poll again to see if we can contact this machine on either of its reported addresses.
|
||||
ReachabilityTuple confirmationReachTuple = pollForReachability(initialReachTuple.computer);
|
||||
if (confirmationReachTuple == null) {
|
||||
// Neither of those seem to work, so we'll hold onto the address that did work
|
||||
initialReachTuple.computer.localAddress = initialReachTuple.reachableAddress;
|
||||
initialReachTuple.computer.reachability = ComputerDetails.Reachability.LOCAL;
|
||||
}
|
||||
else {
|
||||
// We got it on one of the returned addresses; replace the original reach tuple
|
||||
// with the new one
|
||||
initialReachTuple = confirmationReachTuple;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save some details about the old state of the PC that we may wish
|
||||
// to restore later.
|
||||
String savedMacAddress = details.macAddress;
|
||||
String savedLocalAddress = details.localAddress;
|
||||
String savedRemoteAddress = details.remoteAddress;
|
||||
|
||||
// If we got here, it's reachable
|
||||
details.update(initialReachTuple.computer);
|
||||
|
||||
// If the new MAC address is empty, restore the old one (workaround for GFE bug)
|
||||
if (details.macAddress.equals("00:00:00:00:00:00") && savedMacAddress != null) {
|
||||
LimeLog.info("MAC address was empty; using existing value: "+savedMacAddress);
|
||||
details.macAddress = savedMacAddress;
|
||||
if (polledDetails != null) {
|
||||
details.update(polledDetails);
|
||||
return true;
|
||||
}
|
||||
|
||||
// We never want to lose IP addresses by polling server info. If we get a poll back
|
||||
// where localAddress == remoteAddress but savedLocalAddress != savedRemoteAddress,
|
||||
// then we've lost an address in the polling and we should restore the one that's missing.
|
||||
if (details.localAddress.equals(details.remoteAddress) &&
|
||||
!savedLocalAddress.equals(savedRemoteAddress)) {
|
||||
if (details.localAddress.equals(savedLocalAddress)) {
|
||||
// Local addresses are identical, so put the old remote address back
|
||||
details.remoteAddress = savedRemoteAddress;
|
||||
}
|
||||
else if (details.remoteAddress.equals(savedRemoteAddress)) {
|
||||
// Remote addresses are identical, so put the old local address back
|
||||
details.localAddress = savedLocalAddress;
|
||||
}
|
||||
else {
|
||||
// Neither IP address match. Let's restore the remote address to be safe.
|
||||
details.remoteAddress = savedRemoteAddress;
|
||||
}
|
||||
|
||||
// Now update the reachability so the correct address is used
|
||||
if (details.localAddress.equals(initialReachTuple.reachableAddress)) {
|
||||
details.reachability = ComputerDetails.Reachability.LOCAL;
|
||||
}
|
||||
else {
|
||||
details.reachability = ComputerDetails.Reachability.REMOTE;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -653,7 +720,7 @@ public class ComputerManagerService extends Service {
|
||||
|
||||
for (ComputerDetails computer : dbManager.getAllComputers()) {
|
||||
// Add tuples for each computer
|
||||
addTuple(computer, true);
|
||||
addTuple(computer);
|
||||
}
|
||||
|
||||
releaseLocalDatabaseReference();
|
||||
@@ -731,8 +798,9 @@ public class ComputerManagerService extends Service {
|
||||
public void run() {
|
||||
int emptyAppListResponses = 0;
|
||||
do {
|
||||
// Can't poll if it's not online
|
||||
if (computer.state != ComputerDetails.State.ONLINE) {
|
||||
// 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);
|
||||
}
|
||||
@@ -748,7 +816,7 @@ public class ComputerManagerService extends Service {
|
||||
|
||||
try {
|
||||
NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), idManager.getUniqueId(),
|
||||
null, PlatformBinding.getCryptoProvider(ComputerManagerService.this));
|
||||
computer.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this));
|
||||
|
||||
String appList;
|
||||
if (tuple != null) {
|
||||
@@ -772,12 +840,12 @@ public class ComputerManagerService extends Service {
|
||||
// in a row, we'll go ahead and believe it.
|
||||
emptyAppListResponses++;
|
||||
}
|
||||
if (appList != null && !appList.isEmpty() &&
|
||||
if (!appList.isEmpty() &&
|
||||
(!list.isEmpty() || emptyAppListResponses >= EMPTY_LIST_THRESHOLD)) {
|
||||
// Open the cache file
|
||||
OutputStream cacheOut = null;
|
||||
try {
|
||||
cacheOut = CacheHelper.openCacheFileForOutput(getCacheDir(), "applist", computer.uuid.toString());
|
||||
cacheOut = CacheHelper.openCacheFileForOutput(getCacheDir(), "applist", computer.uuid);
|
||||
CacheHelper.writeStringToOutputStream(cacheOut, appList);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
@@ -804,7 +872,7 @@ public class ComputerManagerService extends Service {
|
||||
listener.notifyComputerUpdated(computer);
|
||||
}
|
||||
}
|
||||
else if (appList == null || appList.isEmpty()) {
|
||||
else if (appList.isEmpty()) {
|
||||
LimeLog.warning("Null app list received from "+computer.uuid);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
@@ -815,6 +883,7 @@ public class ComputerManagerService extends Service {
|
||||
} while (waitPollingDelay());
|
||||
}
|
||||
};
|
||||
thread.setName("App list polling thread for " + computer.name);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.limelight.grid;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
@@ -14,9 +15,14 @@ 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> {
|
||||
@@ -24,15 +30,63 @@ public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
||||
private static final int SMALL_WIDTH_DP = 100;
|
||||
private static final int LARGE_WIDTH_DP = 150;
|
||||
|
||||
private final CachedAppAssetLoader loader;
|
||||
private final ComputerDetails computer;
|
||||
private final String uniqueId;
|
||||
private final boolean showHiddenApps;
|
||||
|
||||
public AppGridAdapter(Activity activity, boolean listMode, boolean small, ComputerDetails computer, String uniqueId) {
|
||||
super(activity, listMode ? R.layout.simple_row : (small ? R.layout.app_grid_item_small : R.layout.app_grid_item));
|
||||
private CachedAppAssetLoader loader;
|
||||
private Set<Integer> hiddenAppIds = new HashSet<>();
|
||||
private ArrayList<AppView.AppObject> allApps = new ArrayList<>();
|
||||
|
||||
int dpi = activity.getResources().getDisplayMetrics().densityDpi;
|
||||
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 (small) {
|
||||
if (prefs.smallIconMode) {
|
||||
dp = SMALL_WIDTH_DP;
|
||||
}
|
||||
else {
|
||||
@@ -46,13 +100,19 @@ public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
||||
}
|
||||
LimeLog.info("Art scaling divisor: " + scalingDivisor);
|
||||
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inSampleSize = (int) 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.getCacheDir()));
|
||||
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() {
|
||||
@@ -61,8 +121,8 @@ public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
||||
loader.freeCacheMemory();
|
||||
}
|
||||
|
||||
private void sortList() {
|
||||
Collections.sort(itemList, new Comparator<AppView.AppObject>() {
|
||||
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());
|
||||
@@ -71,43 +131,54 @@ public class AppGridAdapter extends GenericGridAdapter<AppView.AppObject> {
|
||||
}
|
||||
|
||||
public void addApp(AppView.AppObject app) {
|
||||
// Queue a request to fetch this bitmap into cache
|
||||
loader.queueCacheLoad(app.app);
|
||||
// Update hidden state
|
||||
app.isHidden = hiddenAppIds.contains(app.app.getAppId());
|
||||
|
||||
// Add the app to our sorted list
|
||||
itemList.add(app);
|
||||
sortList();
|
||||
// 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 boolean populateImageView(ImageView imgView, ProgressBar prgView, AppView.AppObject obj) {
|
||||
public void clear() {
|
||||
super.clear();
|
||||
allApps.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, AppView.AppObject obj) {
|
||||
// Let the cached asset loader handle it
|
||||
loader.populateImageView(obj.app, imgView, prgView);
|
||||
return true;
|
||||
}
|
||||
loader.populateImageView(obj.app, imgView, txtView);
|
||||
|
||||
@Override
|
||||
public boolean populateTextView(TextView txtView, AppView.AppObject obj) {
|
||||
// Select the text view so it starts marquee mode
|
||||
txtView.setSelected(true);
|
||||
|
||||
// Return false to use the app's toString method
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean populateOverlayView(ImageView overlayView, AppView.AppObject obj) {
|
||||
if (obj.isRunning) {
|
||||
// Show the play button overlay
|
||||
overlayView.setImageResource(R.drawable.ic_play);
|
||||
return true;
|
||||
overlayView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
else {
|
||||
overlayView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
// No overlay
|
||||
return false;
|
||||
if (obj.isHidden) {
|
||||
parentView.setAlpha(0.40f);
|
||||
}
|
||||
else {
|
||||
parentView.setAlpha(1.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,17 +15,26 @@ import java.util.ArrayList;
|
||||
|
||||
public abstract class GenericGridAdapter<T> extends BaseAdapter {
|
||||
protected final Context context;
|
||||
protected final int layoutId;
|
||||
protected final ArrayList<T> itemList = new ArrayList<>();
|
||||
protected final LayoutInflater inflater;
|
||||
private int layoutId;
|
||||
final ArrayList<T> itemList = new ArrayList<>();
|
||||
private final LayoutInflater inflater;
|
||||
|
||||
public GenericGridAdapter(Context context, int layoutId) {
|
||||
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();
|
||||
}
|
||||
@@ -45,9 +54,7 @@ public abstract class GenericGridAdapter<T> extends BaseAdapter {
|
||||
return i;
|
||||
}
|
||||
|
||||
public abstract boolean populateImageView(ImageView imgView, ProgressBar prgView, T obj);
|
||||
public abstract boolean populateTextView(TextView txtView, T obj);
|
||||
public abstract boolean populateOverlayView(ImageView overlayView, T obj);
|
||||
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) {
|
||||
@@ -60,22 +67,7 @@ public abstract class GenericGridAdapter<T> extends BaseAdapter {
|
||||
TextView txtView = convertView.findViewById(R.id.grid_text);
|
||||
ProgressBar prgView = convertView.findViewById(R.id.grid_spinner);
|
||||
|
||||
if (imgView != null) {
|
||||
if (!populateImageView(imgView, prgView, itemList.get(i))) {
|
||||
imgView.setImageBitmap(null);
|
||||
}
|
||||
}
|
||||
if (!populateTextView(txtView, itemList.get(i))) {
|
||||
txtView.setText(itemList.get(i).toString());
|
||||
}
|
||||
if (overlayView != null) {
|
||||
if (!populateOverlayView(overlayView, itemList.get(i))) {
|
||||
overlayView.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
else {
|
||||
overlayView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
populateView(convertView, imgView, prgView, txtView, overlayView, itemList.get(i));
|
||||
|
||||
return convertView;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,25 @@ 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, boolean listMode, boolean small) {
|
||||
super(context, listMode ? R.layout.simple_row : (small ? R.layout.pc_grid_item_small : R.layout.pc_grid_item));
|
||||
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) {
|
||||
@@ -38,7 +49,8 @@ public class PcGridAdapter extends GenericGridAdapter<PcView.ComputerObject> {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean populateImageView(ImageView imgView, ProgressBar prgView, PcView.ComputerObject obj) {
|
||||
public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, PcView.ComputerObject obj) {
|
||||
imgView.setImageResource(R.drawable.ic_computer);
|
||||
if (obj.details.state == ComputerDetails.State.ONLINE) {
|
||||
imgView.setAlpha(1.0f);
|
||||
}
|
||||
@@ -46,19 +58,14 @@ public class PcGridAdapter extends GenericGridAdapter<PcView.ComputerObject> {
|
||||
imgView.setAlpha(0.4f);
|
||||
}
|
||||
|
||||
if (obj.details.reachability == ComputerDetails.Reachability.UNKNOWN) {
|
||||
if (obj.details.state == ComputerDetails.State.UNKNOWN) {
|
||||
prgView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
else {
|
||||
prgView.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
imgView.setImageResource(R.drawable.ic_computer);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean populateTextView(TextView txtView, PcView.ComputerObject obj) {
|
||||
txtView.setText(obj.details.name);
|
||||
if (obj.details.state == ComputerDetails.State.ONLINE) {
|
||||
txtView.setAlpha(1.0f);
|
||||
}
|
||||
@@ -66,17 +73,21 @@ public class PcGridAdapter extends GenericGridAdapter<PcView.ComputerObject> {
|
||||
txtView.setAlpha(0.4f);
|
||||
}
|
||||
|
||||
// Return false to use the computer's toString method
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean populateOverlayView(ImageView overlayView, PcView.ComputerObject obj) {
|
||||
if (obj.details.state == ComputerDetails.State.OFFLINE) {
|
||||
overlayView.setImageResource(R.drawable.ic_pc_offline);
|
||||
overlayView.setAlpha(0.4f);
|
||||
return true;
|
||||
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);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ 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.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.limelight.R;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
|
||||
@@ -52,15 +55,17 @@ public class CachedAppAssetLoader {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -87,7 +92,7 @@ public class CachedAppAssetLoader {
|
||||
memoryLoader.clearCache();
|
||||
}
|
||||
|
||||
private Bitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) {
|
||||
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
|
||||
@@ -108,7 +113,7 @@ public class CachedAppAssetLoader {
|
||||
// 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
|
||||
Bitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
||||
ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
||||
if (bmp != null) {
|
||||
return bmp;
|
||||
}
|
||||
@@ -130,29 +135,29 @@ public class CachedAppAssetLoader {
|
||||
return null;
|
||||
}
|
||||
|
||||
private class LoaderTask extends AsyncTask<LoaderTuple, Void, Bitmap> {
|
||||
private class LoaderTask extends AsyncTask<LoaderTuple, Void, ScaledBitmap> {
|
||||
private final WeakReference<ImageView> imageViewRef;
|
||||
private final WeakReference<ProgressBar> progressViewRef;
|
||||
private final WeakReference<TextView> textViewRef;
|
||||
private final boolean diskOnly;
|
||||
|
||||
private LoaderTuple tuple;
|
||||
|
||||
public LoaderTask(ImageView imageView, ProgressBar prgView, boolean diskOnly) {
|
||||
public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly) {
|
||||
this.imageViewRef = new WeakReference<>(imageView);
|
||||
this.progressViewRef = new WeakReference<>(prgView);
|
||||
this.textViewRef = new WeakReference<>(textView);
|
||||
this.diskOnly = diskOnly;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Bitmap doInBackground(LoaderTuple... params) {
|
||||
protected ScaledBitmap doInBackground(LoaderTuple... params) {
|
||||
tuple = params[0];
|
||||
|
||||
// Check whether it has been cancelled or the views are gone
|
||||
if (isCancelled() || imageViewRef.get() == null || progressViewRef.get() == null) {
|
||||
if (isCancelled() || imageViewRef.get() == null || textViewRef.get() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Bitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
||||
ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
|
||||
if (bmp == null) {
|
||||
if (!diskOnly) {
|
||||
// Try to load the asset from the network
|
||||
@@ -181,44 +186,61 @@ public class CachedAppAssetLoader {
|
||||
|
||||
// If the current loader task for this view isn't us, do nothing
|
||||
final ImageView imageView = imageViewRef.get();
|
||||
final ProgressBar prgView = progressViewRef.get();
|
||||
final TextView textView = textViewRef.get();
|
||||
if (getLoaderTask(imageView) == this) {
|
||||
// Now display the progress bar since we have to hit the network
|
||||
if (prgView != null) {
|
||||
prgView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
// Set off another loader task on the network executor
|
||||
LoaderTask task = new LoaderTask(imageView, prgView, false);
|
||||
AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getResources(), placeholderBitmap, task);
|
||||
imageView.setVisibility(View.VISIBLE);
|
||||
// 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(Bitmap bitmap) {
|
||||
protected void onPostExecute(final ScaledBitmap bitmap) {
|
||||
// Do nothing if cancelled
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ImageView imageView = imageViewRef.get();
|
||||
final ProgressBar prgView = progressViewRef.get();
|
||||
final TextView textView = textViewRef.get();
|
||||
if (getLoaderTask(imageView) == this) {
|
||||
// Set the bitmap
|
||||
// Fade in the box art
|
||||
if (bitmap != null) {
|
||||
imageView.setImageBitmap(bitmap);
|
||||
}
|
||||
// Show the text if it's a placeholder
|
||||
textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE);
|
||||
|
||||
// Hide the progress bar
|
||||
if (prgView != null) {
|
||||
prgView.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
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) {}
|
||||
|
||||
// Show the view
|
||||
imageView.setVisibility(View.VISIBLE);
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,7 +318,13 @@ public class CachedAppAssetLoader {
|
||||
});
|
||||
}
|
||||
|
||||
public boolean populateImageView(NvApp app, ImageView imgView, ProgressBar prgView) {
|
||||
private boolean isBitmapPlaceholder(ScaledBitmap bitmap) {
|
||||
return (bitmap == null) ||
|
||||
(bitmap.originalWidth == 130 && bitmap.originalHeight == 180) || // GFE 2.0
|
||||
(bitmap.originalWidth == 628 && bitmap.originalHeight == 888); // GFE 3.0
|
||||
}
|
||||
|
||||
public boolean populateImageView(NvApp app, ImageView imgView, TextView textView) {
|
||||
LoaderTuple tuple = new LoaderTuple(computer, app);
|
||||
|
||||
// If there's already a task in progress for this view,
|
||||
@@ -306,22 +334,26 @@ public class CachedAppAssetLoader {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hide the progress bar always on initial load
|
||||
prgView.setVisibility(View.INVISIBLE);
|
||||
// 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
|
||||
Bitmap bmp = memoryLoader.loadBitmapFromCache(tuple);
|
||||
ScaledBitmap bmp = memoryLoader.loadBitmapFromCache(tuple);
|
||||
if (bmp != null) {
|
||||
// Show the bitmap immediately
|
||||
imgView.setVisibility(View.VISIBLE);
|
||||
imgView.setImageBitmap(bmp);
|
||||
imgView.setImageBitmap(bmp.bitmap);
|
||||
|
||||
// Show the text if it's a placeholder bitmap
|
||||
textView.setVisibility(isBitmapPlaceholder(bmp) ? View.VISIBLE : View.GONE);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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, prgView, true);
|
||||
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);
|
||||
|
||||
@@ -330,7 +362,7 @@ public class CachedAppAssetLoader {
|
||||
return false;
|
||||
}
|
||||
|
||||
public class LoaderTuple {
|
||||
public static class LoaderTuple {
|
||||
public final ComputerDetails computer;
|
||||
public final NvApp app;
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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;
|
||||
@@ -13,55 +17,147 @@ import java.io.OutputStream;
|
||||
|
||||
public class DiskAssetLoader {
|
||||
// 5 MB
|
||||
private final long MAX_ASSET_SIZE = 5 * 1024 * 1024;
|
||||
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(File cacheDir) {
|
||||
this.cacheDir = 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.toString(), tuple.app.getAppId() + ".png");
|
||||
return CacheHelper.cacheFileExists(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
|
||||
}
|
||||
|
||||
public Bitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, int sampleSize) {
|
||||
InputStream in = null;
|
||||
Bitmap bmp = null;
|
||||
try {
|
||||
// Make sure the cached asset doesn't exceed the maximum size
|
||||
if (CacheHelper.getFileSize(cacheDir, "boxart", tuple.computer.uuid.toString(), tuple.app.getAppId() + ".png") > MAX_ASSET_SIZE) {
|
||||
LimeLog.warning("Removing cached tuple exceeding size threshold: "+tuple);
|
||||
CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid.toString(), 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;
|
||||
}
|
||||
|
||||
in = CacheHelper.openCacheFileForInput(cacheDir, "boxart", tuple.computer.uuid.toString(), tuple.app.getAppId() + ".png");
|
||||
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 = sampleSize;
|
||||
options.inPreferredConfig = Bitmap.Config.RGB_565;
|
||||
bmp = BitmapFactory.decodeStream(in, null, options);
|
||||
} catch (IOException ignored) {
|
||||
} finally {
|
||||
if (in != null) {
|
||||
try {
|
||||
in.close();
|
||||
} catch (IOException ignored) {}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (bmp != null) {
|
||||
LimeLog.info("Disk cache hit for tuple: "+tuple);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return bmp;
|
||||
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.toString(), tuple.app.getAppId() + ".png");
|
||||
out = CacheHelper.openCacheFileForOutput(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
|
||||
CacheHelper.writeInputStreamToOutputStream(input, out, MAX_ASSET_SIZE);
|
||||
success = true;
|
||||
} catch (IOException e) {
|
||||
@@ -75,7 +171,7 @@ public class DiskAssetLoader {
|
||||
|
||||
if (!success) {
|
||||
LimeLog.warning("Unable to populate cache with tuple: "+tuple);
|
||||
CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid.toString(), tuple.app.getAppId() + ".png");
|
||||
CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,74 @@
|
||||
package com.limelight.grid.assets;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.util.LruCache;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.util.HashMap;
|
||||
|
||||
public class MemoryAssetLoader {
|
||||
private static final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
|
||||
private static final LruCache<String, Bitmap> memoryCache = new LruCache<String, Bitmap>(maxMemory / 16) {
|
||||
private static final LruCache<String, ScaledBitmap> memoryCache = new LruCache<String, ScaledBitmap>(maxMemory / 16) {
|
||||
@Override
|
||||
protected int sizeOf(String key, Bitmap bitmap) {
|
||||
protected int sizeOf(String key, ScaledBitmap bitmap) {
|
||||
// Sizeof returns kilobytes
|
||||
return bitmap.getByteCount() / 1024;
|
||||
return bitmap.bitmap.getByteCount() / 1024;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void entryRemoved(boolean evicted, String key, ScaledBitmap oldValue, ScaledBitmap newValue) {
|
||||
super.entryRemoved(evicted, key, oldValue, newValue);
|
||||
|
||||
if (evicted) {
|
||||
// Keep a soft reference around to the bitmap as long as we can
|
||||
evictionCache.put(key, new SoftReference<>(oldValue));
|
||||
}
|
||||
}
|
||||
};
|
||||
private static final HashMap<String, SoftReference<ScaledBitmap>> evictionCache = new HashMap<>();
|
||||
|
||||
private static String constructKey(CachedAppAssetLoader.LoaderTuple tuple) {
|
||||
return tuple.computer.uuid.toString()+"-"+tuple.app.getAppId();
|
||||
return tuple.computer.uuid+"-"+tuple.app.getAppId();
|
||||
}
|
||||
|
||||
public Bitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple) {
|
||||
Bitmap bmp = memoryCache.get(constructKey(tuple));
|
||||
public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple) {
|
||||
final String key = constructKey(tuple);
|
||||
|
||||
ScaledBitmap bmp = memoryCache.get(key);
|
||||
if (bmp != null) {
|
||||
LimeLog.info("Memory cache hit for tuple: "+tuple);
|
||||
LimeLog.info("LRU cache hit for tuple: "+tuple);
|
||||
return bmp;
|
||||
}
|
||||
return bmp;
|
||||
|
||||
SoftReference<ScaledBitmap> bmpRef = evictionCache.get(key);
|
||||
if (bmpRef != null) {
|
||||
bmp = bmpRef.get();
|
||||
if (bmp != null) {
|
||||
LimeLog.info("Eviction cache hit for tuple: "+tuple);
|
||||
|
||||
// Put this entry back into the LRU cache
|
||||
evictionCache.remove(key);
|
||||
memoryCache.put(key, bmp);
|
||||
|
||||
return bmp;
|
||||
}
|
||||
else {
|
||||
// The data is gone, so remove the dangling SoftReference now
|
||||
evictionCache.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void populateCache(CachedAppAssetLoader.LoaderTuple tuple, Bitmap bitmap) {
|
||||
public void populateCache(CachedAppAssetLoader.LoaderTuple tuple, ScaledBitmap bitmap) {
|
||||
memoryCache.put(constructKey(tuple), bitmap);
|
||||
}
|
||||
|
||||
public void clearCache() {
|
||||
// We must evict first because that will push all items into the eviction cache
|
||||
memoryCache.evictAll();
|
||||
evictionCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ public class NetworkAssetLoader {
|
||||
public InputStream getBitmapStream(CachedAppAssetLoader.LoaderTuple tuple) {
|
||||
InputStream in = null;
|
||||
try {
|
||||
NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(tuple.computer), uniqueId, null, PlatformBinding.getCryptoProvider(context));
|
||||
NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(tuple.computer), uniqueId,
|
||||
tuple.computer.serverCert, PlatformBinding.getCryptoProvider(context));
|
||||
in = http.getBoxArt(tuple.app);
|
||||
} catch (IOException ignored) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.limelight.grid.assets;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
|
||||
public class ScaledBitmap {
|
||||
public int originalWidth;
|
||||
public int originalHeight;
|
||||
|
||||
public Bitmap bitmap;
|
||||
|
||||
public ScaledBitmap() {}
|
||||
|
||||
public ScaledBitmap(int originalWidth, int originalHeight, Bitmap bitmap) {
|
||||
this.originalWidth = originalWidth;
|
||||
this.originalHeight = originalHeight;
|
||||
this.bitmap = bitmap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.limelight.nvstream;
|
||||
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
public class ConnectionContext {
|
||||
public String serverAddress;
|
||||
public X509Certificate serverCert;
|
||||
public StreamConfiguration streamConfig;
|
||||
public NvConnectionListener connListener;
|
||||
public SecretKey riKey;
|
||||
public int riKeyId;
|
||||
|
||||
// This is the version quad from the appversion tag of /serverinfo
|
||||
public String serverAppVersion;
|
||||
public String serverGfeVersion;
|
||||
|
||||
public int negotiatedWidth, negotiatedHeight;
|
||||
public boolean negotiatedHdr;
|
||||
|
||||
public int videoCapabilities;
|
||||
}
|
||||
362
app/src/main/java/com/limelight/nvstream/NvConnection.java
Normal file
362
app/src/main/java/com/limelight/nvstream/NvConnection.java
Normal file
@@ -0,0 +1,362 @@
|
||||
package com.limelight.nvstream;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.nvstream.av.audio.AudioRenderer;
|
||||
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
|
||||
import com.limelight.nvstream.http.GfeHttpResponseException;
|
||||
import com.limelight.nvstream.http.LimelightCryptoProvider;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
import com.limelight.nvstream.http.NvHTTP;
|
||||
import com.limelight.nvstream.http.PairingManager;
|
||||
import com.limelight.nvstream.input.MouseButtonPacket;
|
||||
import com.limelight.nvstream.jni.MoonBridge;
|
||||
|
||||
public class NvConnection {
|
||||
// Context parameters
|
||||
private String host;
|
||||
private LimelightCryptoProvider cryptoProvider;
|
||||
private String uniqueId;
|
||||
private ConnectionContext context;
|
||||
private static Semaphore connectionAllowed = new Semaphore(1);
|
||||
private final boolean isMonkey;
|
||||
|
||||
public NvConnection(String host, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert)
|
||||
{
|
||||
this.host = host;
|
||||
this.cryptoProvider = cryptoProvider;
|
||||
this.uniqueId = uniqueId;
|
||||
|
||||
this.context = new ConnectionContext();
|
||||
this.context.streamConfig = config;
|
||||
this.context.serverCert = serverCert;
|
||||
|
||||
// This is unique per connection
|
||||
this.context.riKey = generateRiAesKey();
|
||||
context.riKeyId = generateRiKeyId();
|
||||
|
||||
this.isMonkey = ActivityManager.isUserAMonkey();
|
||||
}
|
||||
|
||||
private static SecretKey generateRiAesKey() {
|
||||
try {
|
||||
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
|
||||
|
||||
// RI keys are 128 bits
|
||||
keyGen.init(128);
|
||||
|
||||
return keyGen.generateKey();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static int generateRiKeyId() {
|
||||
return new SecureRandom().nextInt();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
// Interrupt any pending connection. This is thread-safe.
|
||||
MoonBridge.interruptConnection();
|
||||
|
||||
// Moonlight-core is not thread-safe with respect to connection start and stop, so
|
||||
// we must not invoke that functionality in parallel.
|
||||
synchronized (MoonBridge.class) {
|
||||
MoonBridge.stopConnection();
|
||||
MoonBridge.cleanupBridge();
|
||||
}
|
||||
|
||||
// Now a pending connection can be processed
|
||||
connectionAllowed.release();
|
||||
}
|
||||
|
||||
private boolean startApp() throws XmlPullParserException, IOException
|
||||
{
|
||||
NvHTTP h = new NvHTTP(context.serverAddress, uniqueId, context.serverCert, cryptoProvider);
|
||||
|
||||
String serverInfo = h.getServerInfo();
|
||||
|
||||
context.serverAppVersion = h.getServerVersion(serverInfo);
|
||||
if (context.serverAppVersion == null) {
|
||||
context.connListener.displayMessage("Server version malformed");
|
||||
return false;
|
||||
}
|
||||
|
||||
// May be missing for older servers
|
||||
context.serverGfeVersion = h.getGfeVersion(serverInfo);
|
||||
|
||||
if (h.getPairState(serverInfo) != PairingManager.PairState.PAIRED) {
|
||||
context.connListener.displayMessage("Device not paired with computer");
|
||||
return false;
|
||||
}
|
||||
|
||||
context.negotiatedHdr = context.streamConfig.getEnableHdr();
|
||||
if ((h.getServerCodecModeSupport(serverInfo) & 0x200) == 0 && context.negotiatedHdr) {
|
||||
context.connListener.displayTransientMessage("Your GPU does not support streaming HDR. The stream will be SDR.");
|
||||
context.negotiatedHdr = false;
|
||||
}
|
||||
|
||||
//
|
||||
// Decide on negotiated stream parameters now
|
||||
//
|
||||
|
||||
// Check for a supported stream resolution
|
||||
if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) {
|
||||
// Client wants 4K but the server can't do it
|
||||
context.connListener.displayTransientMessage("You must update GeForce Experience to stream in 4K. The stream will be 1080p.");
|
||||
|
||||
// Lower resolution to 1080p
|
||||
context.negotiatedWidth = 1920;
|
||||
context.negotiatedHeight = 1080;
|
||||
}
|
||||
else {
|
||||
// Take what the client wanted
|
||||
context.negotiatedWidth = context.streamConfig.getWidth();
|
||||
context.negotiatedHeight = context.streamConfig.getHeight();
|
||||
}
|
||||
|
||||
//
|
||||
// Video stream format will be decided during the RTSP handshake
|
||||
//
|
||||
|
||||
NvApp app = context.streamConfig.getApp();
|
||||
|
||||
// If the client did not provide an exact app ID, do a lookup with the applist
|
||||
if (!context.streamConfig.getApp().isInitialized()) {
|
||||
LimeLog.info("Using deprecated app lookup method - Please specify an app ID in your StreamConfiguration instead");
|
||||
app = h.getAppByName(context.streamConfig.getApp().getAppName());
|
||||
if (app == null) {
|
||||
context.connListener.displayMessage("The app " + context.streamConfig.getApp().getAppName() + " is not in GFE app list");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a game running, resume it
|
||||
if (h.getCurrentGame(serverInfo) != 0) {
|
||||
try {
|
||||
if (h.getCurrentGame(serverInfo) == app.getAppId()) {
|
||||
if (!h.resumeApp(context)) {
|
||||
context.connListener.displayMessage("Failed to resume existing session");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return quitAndLaunch(h, context);
|
||||
}
|
||||
} catch (GfeHttpResponseException e) {
|
||||
if (e.getErrorCode() == 470) {
|
||||
// This is the error you get when you try to resume a session that's not yours.
|
||||
// Because this is fairly common, we'll display a more detailed message.
|
||||
context.connListener.displayMessage("This session wasn't started by this device," +
|
||||
" so it cannot be resumed. End streaming on the original " +
|
||||
"device or the PC itself and try again. (Error code: "+e.getErrorCode()+")");
|
||||
return false;
|
||||
}
|
||||
else if (e.getErrorCode() == 525) {
|
||||
context.connListener.displayMessage("The application is minimized. Resume it on the PC manually or " +
|
||||
"quit the session and start streaming again.");
|
||||
return false;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
LimeLog.info("Resumed existing game session");
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return launchNotRunningApp(h, context);
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean quitAndLaunch(NvHTTP h, ConnectionContext context) throws IOException,
|
||||
XmlPullParserException {
|
||||
try {
|
||||
if (!h.quitApp()) {
|
||||
context.connListener.displayMessage("Failed to quit previous session! You must quit it manually");
|
||||
return false;
|
||||
}
|
||||
} catch (GfeHttpResponseException e) {
|
||||
if (e.getErrorCode() == 599) {
|
||||
context.connListener.displayMessage("This session wasn't started by this device," +
|
||||
" so it cannot be quit. End streaming on the original " +
|
||||
"device or the PC itself. (Error code: "+e.getErrorCode()+")");
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return launchNotRunningApp(h, context);
|
||||
}
|
||||
|
||||
private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context)
|
||||
throws IOException, XmlPullParserException {
|
||||
// Launch the app since it's not running
|
||||
if (!h.launchApp(context, context.streamConfig.getApp().getAppId(), context.negotiatedHdr)) {
|
||||
context.connListener.displayMessage("Failed to launch application");
|
||||
return false;
|
||||
}
|
||||
|
||||
LimeLog.info("Launched new game session");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void start(final AudioRenderer audioRenderer, final VideoDecoderRenderer videoDecoderRenderer, final NvConnectionListener connectionListener)
|
||||
{
|
||||
new Thread(new Runnable() {
|
||||
public void run() {
|
||||
context.connListener = connectionListener;
|
||||
context.videoCapabilities = videoDecoderRenderer.getCapabilities();
|
||||
|
||||
String appName = context.streamConfig.getApp().getAppName();
|
||||
|
||||
context.serverAddress = host;
|
||||
context.connListener.stageStarting(appName);
|
||||
|
||||
try {
|
||||
if (!startApp()) {
|
||||
context.connListener.stageFailed(appName, 0, 0);
|
||||
return;
|
||||
}
|
||||
context.connListener.stageComplete(appName);
|
||||
} catch (GfeHttpResponseException e) {
|
||||
e.printStackTrace();
|
||||
context.connListener.displayMessage(e.getMessage());
|
||||
context.connListener.stageFailed(appName, 0, e.getErrorCode());
|
||||
return;
|
||||
} catch (XmlPullParserException | IOException e) {
|
||||
e.printStackTrace();
|
||||
context.connListener.displayMessage(e.getMessage());
|
||||
context.connListener.stageFailed(appName, MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
ByteBuffer ib = ByteBuffer.allocate(16);
|
||||
ib.putInt(context.riKeyId);
|
||||
|
||||
// Acquire the connection semaphore to ensure we only have one
|
||||
// connection going at once.
|
||||
try {
|
||||
connectionAllowed.acquire();
|
||||
} catch (InterruptedException e) {
|
||||
context.connListener.displayMessage(e.getMessage());
|
||||
context.connListener.stageFailed(appName, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Moonlight-core is not thread-safe with respect to connection start and stop, so
|
||||
// we must not invoke that functionality in parallel.
|
||||
synchronized (MoonBridge.class) {
|
||||
MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener);
|
||||
int ret = MoonBridge.startConnection(context.serverAddress,
|
||||
context.serverAppVersion, context.serverGfeVersion,
|
||||
context.negotiatedWidth, context.negotiatedHeight,
|
||||
context.streamConfig.getRefreshRate(), context.streamConfig.getBitrate(),
|
||||
context.streamConfig.getMaxPacketSize(),
|
||||
context.streamConfig.getRemote(), context.streamConfig.getAudioConfiguration().toInt(),
|
||||
context.streamConfig.getHevcSupported(),
|
||||
context.negotiatedHdr,
|
||||
context.streamConfig.getHevcBitratePercentageMultiplier(),
|
||||
context.streamConfig.getClientRefreshRateX100(),
|
||||
context.riKey.getEncoded(), ib.array(),
|
||||
context.videoCapabilities);
|
||||
if (ret != 0) {
|
||||
// LiStartConnection() failed, so the caller is not expected
|
||||
// to stop the connection themselves. We need to release their
|
||||
// semaphore count for them.
|
||||
connectionAllowed.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void sendMouseMove(final short deltaX, final short deltaY)
|
||||
{
|
||||
if (!isMonkey) {
|
||||
MoonBridge.sendMouseMove(deltaX, deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight)
|
||||
{
|
||||
if (!isMonkey) {
|
||||
MoonBridge.sendMousePosition(x, y, referenceWidth, referenceHeight);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendMouseButtonDown(final byte mouseButton)
|
||||
{
|
||||
if (!isMonkey) {
|
||||
MoonBridge.sendMouseButton(MouseButtonPacket.PRESS_EVENT, mouseButton);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendMouseButtonUp(final byte mouseButton)
|
||||
{
|
||||
if (!isMonkey) {
|
||||
MoonBridge.sendMouseButton(MouseButtonPacket.RELEASE_EVENT, mouseButton);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendControllerInput(final short controllerNumber,
|
||||
final short activeGamepadMask, final short buttonFlags,
|
||||
final byte leftTrigger, final byte rightTrigger,
|
||||
final short leftStickX, final short leftStickY,
|
||||
final short rightStickX, final short rightStickY)
|
||||
{
|
||||
if (!isMonkey) {
|
||||
MoonBridge.sendMultiControllerInput(controllerNumber, activeGamepadMask, buttonFlags,
|
||||
leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendControllerInput(final short buttonFlags,
|
||||
final byte leftTrigger, final byte rightTrigger,
|
||||
final short leftStickX, final short leftStickY,
|
||||
final short rightStickX, final short rightStickY)
|
||||
{
|
||||
if (!isMonkey) {
|
||||
MoonBridge.sendControllerInput(buttonFlags, leftTrigger, rightTrigger, leftStickX,
|
||||
leftStickY, rightStickX, rightStickY);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendKeyboardInput(final short keyMap, final byte keyDirection, final byte modifier) {
|
||||
if (!isMonkey) {
|
||||
MoonBridge.sendKeyboardInput(keyMap, keyDirection, modifier);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendMouseScroll(final byte scrollClicks) {
|
||||
if (!isMonkey) {
|
||||
MoonBridge.sendMouseScroll(scrollClicks);
|
||||
}
|
||||
}
|
||||
|
||||
public void sendMouseHighResScroll(final short scrollAmount) {
|
||||
if (!isMonkey) {
|
||||
MoonBridge.sendMouseHighResScroll(scrollAmount);
|
||||
}
|
||||
}
|
||||
|
||||
public static String findExternalAddressForMdns(String stunHostname, int stunPort) {
|
||||
return MoonBridge.findExternalAddressIP4(stunHostname, stunPort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.limelight.nvstream;
|
||||
|
||||
public interface NvConnectionListener {
|
||||
void stageStarting(String stage);
|
||||
void stageComplete(String stage);
|
||||
void stageFailed(String stage, int portFlags, int errorCode);
|
||||
|
||||
void connectionStarted();
|
||||
void connectionTerminated(int errorCode);
|
||||
void connectionStatusUpdate(int connectionStatus);
|
||||
|
||||
void displayMessage(String message);
|
||||
void displayTransientMessage(String message);
|
||||
|
||||
void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.limelight.nvstream;
|
||||
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
import com.limelight.nvstream.jni.MoonBridge;
|
||||
|
||||
public class StreamConfiguration {
|
||||
public static final int INVALID_APP_ID = 0;
|
||||
|
||||
public static final int STREAM_CFG_LOCAL = 0;
|
||||
public static final int STREAM_CFG_REMOTE = 1;
|
||||
public static final int STREAM_CFG_AUTO = 2;
|
||||
|
||||
private NvApp app;
|
||||
private int width, height;
|
||||
private int refreshRate;
|
||||
private int launchRefreshRate;
|
||||
private int clientRefreshRateX100;
|
||||
private int bitrate;
|
||||
private boolean sops;
|
||||
private boolean enableAdaptiveResolution;
|
||||
private boolean playLocalAudio;
|
||||
private int maxPacketSize;
|
||||
private int remote;
|
||||
private MoonBridge.AudioConfiguration audioConfiguration;
|
||||
private boolean supportsHevc;
|
||||
private int hevcBitratePercentageMultiplier;
|
||||
private boolean enableHdr;
|
||||
private int attachedGamepadMask;
|
||||
|
||||
public static class Builder {
|
||||
private StreamConfiguration config = new StreamConfiguration();
|
||||
|
||||
public StreamConfiguration.Builder setApp(NvApp app) {
|
||||
config.app = app;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setRemoteConfiguration(int remote) {
|
||||
config.remote = remote;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setResolution(int width, int height) {
|
||||
config.width = width;
|
||||
config.height = height;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setRefreshRate(int refreshRate) {
|
||||
config.refreshRate = refreshRate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setLaunchRefreshRate(int refreshRate) {
|
||||
config.launchRefreshRate = refreshRate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setBitrate(int bitrate) {
|
||||
config.bitrate = bitrate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setEnableSops(boolean enable) {
|
||||
config.sops = enable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder enableAdaptiveResolution(boolean enable) {
|
||||
config.enableAdaptiveResolution = enable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder enableLocalAudioPlayback(boolean enable) {
|
||||
config.playLocalAudio = enable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setMaxPacketSize(int maxPacketSize) {
|
||||
config.maxPacketSize = maxPacketSize;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setHevcBitratePercentageMultiplier(int multiplier) {
|
||||
config.hevcBitratePercentageMultiplier = multiplier;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setEnableHdr(boolean enableHdr) {
|
||||
config.enableHdr = enableHdr;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setAttachedGamepadMask(int attachedGamepadMask) {
|
||||
config.attachedGamepadMask = attachedGamepadMask;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setAttachedGamepadMaskByCount(int gamepadCount) {
|
||||
config.attachedGamepadMask = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (gamepadCount > i) {
|
||||
config.attachedGamepadMask |= 1 << i;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setClientRefreshRateX100(int refreshRateX100) {
|
||||
config.clientRefreshRateX100 = refreshRateX100;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setAudioConfiguration(MoonBridge.AudioConfiguration audioConfig) {
|
||||
config.audioConfiguration = audioConfig;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration.Builder setHevcSupported(boolean supportsHevc) {
|
||||
config.supportsHevc = supportsHevc;
|
||||
return this;
|
||||
}
|
||||
|
||||
public StreamConfiguration build() {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
private StreamConfiguration() {
|
||||
// Set default attributes
|
||||
this.app = new NvApp("Steam");
|
||||
this.width = 1280;
|
||||
this.height = 720;
|
||||
this.refreshRate = 60;
|
||||
this.launchRefreshRate = 60;
|
||||
this.bitrate = 10000;
|
||||
this.maxPacketSize = 1024;
|
||||
this.remote = STREAM_CFG_AUTO;
|
||||
this.sops = true;
|
||||
this.enableAdaptiveResolution = false;
|
||||
this.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO;
|
||||
this.supportsHevc = false;
|
||||
this.enableHdr = false;
|
||||
this.attachedGamepadMask = 0;
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public int getRefreshRate() {
|
||||
return refreshRate;
|
||||
}
|
||||
|
||||
public int getLaunchRefreshRate() {
|
||||
return launchRefreshRate;
|
||||
}
|
||||
|
||||
public int getBitrate() {
|
||||
return bitrate;
|
||||
}
|
||||
|
||||
public int getMaxPacketSize() {
|
||||
return maxPacketSize;
|
||||
}
|
||||
|
||||
public NvApp getApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
public boolean getSops() {
|
||||
return sops;
|
||||
}
|
||||
|
||||
public boolean getAdaptiveResolutionEnabled() {
|
||||
return enableAdaptiveResolution;
|
||||
}
|
||||
|
||||
public boolean getPlayLocalAudio() {
|
||||
return playLocalAudio;
|
||||
}
|
||||
|
||||
public int getRemote() {
|
||||
return remote;
|
||||
}
|
||||
|
||||
public MoonBridge.AudioConfiguration getAudioConfiguration() {
|
||||
return audioConfiguration;
|
||||
}
|
||||
|
||||
public boolean getHevcSupported() {
|
||||
return supportsHevc;
|
||||
}
|
||||
|
||||
public int getHevcBitratePercentageMultiplier() {
|
||||
return hevcBitratePercentageMultiplier;
|
||||
}
|
||||
|
||||
public boolean getEnableHdr() {
|
||||
return enableHdr;
|
||||
}
|
||||
|
||||
public int getAttachedGamepadMask() {
|
||||
return attachedGamepadMask;
|
||||
}
|
||||
|
||||
public int getClientRefreshRateX100() {
|
||||
return clientRefreshRateX100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.limelight.nvstream.av;
|
||||
|
||||
public class ByteBufferDescriptor {
|
||||
public byte[] data;
|
||||
public int offset;
|
||||
public int length;
|
||||
|
||||
public ByteBufferDescriptor nextDescriptor;
|
||||
|
||||
public ByteBufferDescriptor(byte[] data, int offset, int length)
|
||||
{
|
||||
this.data = data;
|
||||
this.offset = offset;
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
public ByteBufferDescriptor(ByteBufferDescriptor desc)
|
||||
{
|
||||
this.data = desc.data;
|
||||
this.offset = desc.offset;
|
||||
this.length = desc.length;
|
||||
}
|
||||
|
||||
public void reinitialize(byte[] data, int offset, int length)
|
||||
{
|
||||
this.data = data;
|
||||
this.offset = offset;
|
||||
this.length = length;
|
||||
this.nextDescriptor = null;
|
||||
}
|
||||
|
||||
public void print()
|
||||
{
|
||||
print(offset, length);
|
||||
}
|
||||
|
||||
public void print(int length)
|
||||
{
|
||||
print(this.offset, length);
|
||||
}
|
||||
|
||||
public void print(int offset, int length)
|
||||
{
|
||||
for (int i = offset; i < offset+length;) {
|
||||
if (i + 8 <= offset+length) {
|
||||
System.out.printf("%x: %02x %02x %02x %02x %02x %02x %02x %02x\n", i,
|
||||
data[i], data[i+1], data[i+2], data[i+3], data[i+4], data[i+5], data[i+6], data[i+7]);
|
||||
i += 8;
|
||||
}
|
||||
else {
|
||||
System.out.printf("%x: %02x \n", i, data[i]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
System.out.println();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.limelight.nvstream.av.audio;
|
||||
|
||||
import com.limelight.nvstream.jni.MoonBridge;
|
||||
|
||||
public interface AudioRenderer {
|
||||
int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame);
|
||||
|
||||
void start();
|
||||
|
||||
void stop();
|
||||
|
||||
void playDecodedAudio(short[] audioData);
|
||||
|
||||
void cleanup();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.limelight.nvstream.av.video;
|
||||
|
||||
public abstract class VideoDecoderRenderer {
|
||||
public abstract int setup(int format, int width, int height, int redrawRate);
|
||||
|
||||
public abstract void start();
|
||||
|
||||
public abstract void stop();
|
||||
|
||||
// This is called once for each frame-start NALU. This means it will be called several times
|
||||
// for an IDR frame which contains several parameter sets and the I-frame data.
|
||||
public abstract int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
|
||||
int frameNumber, long receiveTimeMs);
|
||||
|
||||
public abstract void cleanup();
|
||||
|
||||
public abstract int getCapabilities();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.limelight.nvstream.http;
|
||||
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
|
||||
public class ComputerDetails {
|
||||
public enum State {
|
||||
ONLINE, OFFLINE, UNKNOWN
|
||||
}
|
||||
|
||||
// Persistent attributes
|
||||
public String uuid;
|
||||
public String name;
|
||||
public String localAddress;
|
||||
public String remoteAddress;
|
||||
public String manualAddress;
|
||||
public String ipv6Address;
|
||||
public String macAddress;
|
||||
public X509Certificate serverCert;
|
||||
|
||||
// Transient attributes
|
||||
public State state;
|
||||
public String activeAddress;
|
||||
public PairingManager.PairState pairState;
|
||||
public int runningGameId;
|
||||
public String rawAppList;
|
||||
|
||||
public ComputerDetails() {
|
||||
// Use defaults
|
||||
state = State.UNKNOWN;
|
||||
}
|
||||
|
||||
public ComputerDetails(ComputerDetails details) {
|
||||
// Copy details from the other computer
|
||||
update(details);
|
||||
}
|
||||
|
||||
public void update(ComputerDetails details) {
|
||||
this.state = details.state;
|
||||
this.name = details.name;
|
||||
this.uuid = details.uuid;
|
||||
if (details.activeAddress != null) {
|
||||
this.activeAddress = details.activeAddress;
|
||||
}
|
||||
// We can get IPv4 loopback addresses with GS IPv6 Forwarder
|
||||
if (details.localAddress != null && !details.localAddress.startsWith("127.")) {
|
||||
this.localAddress = details.localAddress;
|
||||
}
|
||||
if (details.remoteAddress != null) {
|
||||
this.remoteAddress = details.remoteAddress;
|
||||
}
|
||||
if (details.manualAddress != null) {
|
||||
this.manualAddress = details.manualAddress;
|
||||
}
|
||||
if (details.ipv6Address != null) {
|
||||
this.ipv6Address = details.ipv6Address;
|
||||
}
|
||||
if (details.macAddress != null && !details.macAddress.equals("00:00:00:00:00:00")) {
|
||||
this.macAddress = details.macAddress;
|
||||
}
|
||||
if (details.serverCert != null) {
|
||||
this.serverCert = details.serverCert;
|
||||
}
|
||||
this.pairState = details.pairState;
|
||||
this.runningGameId = details.runningGameId;
|
||||
this.rawAppList = details.rawAppList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder str = new StringBuilder();
|
||||
str.append("Name: ").append(name).append("\n");
|
||||
str.append("State: ").append(state).append("\n");
|
||||
str.append("Active Address: ").append(activeAddress).append("\n");
|
||||
str.append("UUID: ").append(uuid).append("\n");
|
||||
str.append("Local Address: ").append(localAddress).append("\n");
|
||||
str.append("Remote Address: ").append(remoteAddress).append("\n");
|
||||
str.append("IPv6 Address: ").append(ipv6Address).append("\n");
|
||||
str.append("Manual Address: ").append(manualAddress).append("\n");
|
||||
str.append("MAC Address: ").append(macAddress).append("\n");
|
||||
str.append("Pair State: ").append(pairState).append("\n");
|
||||
str.append("Running Game ID: ").append(runningGameId).append("\n");
|
||||
return str.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.limelight.nvstream.http;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class GfeHttpResponseException extends IOException {
|
||||
private static final long serialVersionUID = 1543508830807804222L;
|
||||
|
||||
private int errorCode;
|
||||
private String errorMsg;
|
||||
|
||||
public GfeHttpResponseException(int errorCode, String errorMsg) {
|
||||
this.errorCode = errorCode;
|
||||
this.errorMsg = errorMsg;
|
||||
}
|
||||
|
||||
public int getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage() {
|
||||
return "GeForce Experience returned error: "+errorMsg+" (Error code: "+errorCode+")";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.limelight.nvstream.http;
|
||||
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
|
||||
public interface LimelightCryptoProvider {
|
||||
X509Certificate getClientCertificate();
|
||||
RSAPrivateKey getClientPrivateKey();
|
||||
byte[] getPemEncodedClientCertificate();
|
||||
String encodeBase64String(byte[] data);
|
||||
}
|
||||
70
app/src/main/java/com/limelight/nvstream/http/NvApp.java
Normal file
70
app/src/main/java/com/limelight/nvstream/http/NvApp.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package com.limelight.nvstream.http;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
|
||||
public class NvApp {
|
||||
private String appName = "";
|
||||
private int appId;
|
||||
private boolean initialized;
|
||||
private boolean hdrSupported;
|
||||
|
||||
public NvApp() {}
|
||||
|
||||
public NvApp(String appName) {
|
||||
this.appName = appName;
|
||||
}
|
||||
|
||||
public NvApp(String appName, int appId, boolean hdrSupported) {
|
||||
this.appName = appName;
|
||||
this.appId = appId;
|
||||
this.hdrSupported = hdrSupported;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
public void setAppName(String appName) {
|
||||
this.appName = appName;
|
||||
}
|
||||
|
||||
public void setAppId(String appId) {
|
||||
try {
|
||||
this.appId = Integer.parseInt(appId);
|
||||
this.initialized = true;
|
||||
} catch (NumberFormatException e) {
|
||||
LimeLog.warning("Malformed app ID: "+appId);
|
||||
}
|
||||
}
|
||||
|
||||
public void setAppId(int appId) {
|
||||
this.appId = appId;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
public void setHdrSupported(boolean hdrSupported) {
|
||||
this.hdrSupported = hdrSupported;
|
||||
}
|
||||
|
||||
public String getAppName() {
|
||||
return this.appName;
|
||||
}
|
||||
|
||||
public int getAppId() {
|
||||
return this.appId;
|
||||
}
|
||||
|
||||
public boolean isHdrSupported() {
|
||||
return this.hdrSupported;
|
||||
}
|
||||
|
||||
public boolean isInitialized() {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder str = new StringBuilder();
|
||||
str.append("Name: ").append(appName).append("\n");
|
||||
str.append("HDR: ").append(hdrSupported ? "Yes" : "No").append("\n");
|
||||
str.append("ID: ").append(appId).append("\n");
|
||||
return str.toString();
|
||||
}
|
||||
}
|
||||
771
app/src/main/java/com/limelight/nvstream/http/NvHTTP.java
Normal file
771
app/src/main/java/com/limelight/nvstream/http/NvHTTP.java
Normal file
@@ -0,0 +1,771 @@
|
||||
package com.limelight.nvstream.http;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.io.StringReader;
|
||||
import java.net.InetAddress;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.Principal;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.LinkedList;
|
||||
import java.util.ListIterator;
|
||||
import java.util.Stack;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.KeyManager;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import javax.net.ssl.SSLPeerUnverifiedException;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509KeyManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser;
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
import org.xmlpull.v1.XmlPullParserFactory;
|
||||
|
||||
import com.limelight.BuildConfig;
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.nvstream.ConnectionContext;
|
||||
import com.limelight.nvstream.http.PairingManager.PairState;
|
||||
|
||||
import okhttp3.ConnectionPool;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
|
||||
public class NvHTTP {
|
||||
private String uniqueId;
|
||||
private PairingManager pm;
|
||||
|
||||
public static final int HTTPS_PORT = 47984;
|
||||
public static final int HTTP_PORT = 47989;
|
||||
public static final int CONNECTION_TIMEOUT = 3000;
|
||||
public static final int READ_TIMEOUT = 5000;
|
||||
|
||||
// Print URL and content to logcat on debug builds
|
||||
private static boolean verbose = BuildConfig.DEBUG;
|
||||
|
||||
public String baseUrlHttps;
|
||||
public String baseUrlHttp;
|
||||
|
||||
private OkHttpClient httpClient;
|
||||
private OkHttpClient httpClientWithReadTimeout;
|
||||
|
||||
private X509TrustManager defaultTrustManager;
|
||||
private X509TrustManager trustManager;
|
||||
private X509KeyManager keyManager;
|
||||
private X509Certificate serverCert;
|
||||
|
||||
void setServerCert(X509Certificate serverCert) {
|
||||
this.serverCert = serverCert;
|
||||
}
|
||||
|
||||
private static X509TrustManager getDefaultTrustManager() {
|
||||
try {
|
||||
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init((KeyStore) null);
|
||||
|
||||
for (TrustManager tm : tmf.getTrustManagers()) {
|
||||
if (tm instanceof X509TrustManager) {
|
||||
return (X509TrustManager) tm;
|
||||
}
|
||||
}
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (KeyStoreException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
throw new IllegalStateException("No X509 trust manager found");
|
||||
}
|
||||
|
||||
private void initializeHttpState(final LimelightCryptoProvider cryptoProvider) {
|
||||
keyManager = new X509KeyManager() {
|
||||
public String chooseClientAlias(String[] keyTypes,
|
||||
Principal[] issuers, Socket socket) { return "Limelight-RSA"; }
|
||||
public String chooseServerAlias(String keyType, Principal[] issuers,
|
||||
Socket socket) { return null; }
|
||||
public X509Certificate[] getCertificateChain(String alias) {
|
||||
return new X509Certificate[] {cryptoProvider.getClientCertificate()};
|
||||
}
|
||||
public String[] getClientAliases(String keyType, Principal[] issuers) { return null; }
|
||||
public PrivateKey getPrivateKey(String alias) {
|
||||
return cryptoProvider.getClientPrivateKey();
|
||||
}
|
||||
public String[] getServerAliases(String keyType, Principal[] issuers) { return null; }
|
||||
};
|
||||
|
||||
defaultTrustManager = getDefaultTrustManager();
|
||||
trustManager = new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
public void checkClientTrusted(X509Certificate[] certs, String authType) {
|
||||
throw new IllegalStateException("Should never be called");
|
||||
}
|
||||
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
|
||||
try {
|
||||
// Try the default trust manager first to allow pairing with certificates
|
||||
// that chain up to a trusted root CA. This will raise CertificateException
|
||||
// if the certificate is not trusted (expected for GFE's self-signed certs).
|
||||
defaultTrustManager.checkServerTrusted(certs, authType);
|
||||
} catch (CertificateException e) {
|
||||
// Check the server certificate if we've paired to this host
|
||||
if (certs.length == 1 && NvHTTP.this.serverCert != null) {
|
||||
if (!certs[0].equals(NvHTTP.this.serverCert)) {
|
||||
throw new CertificateException("Certificate mismatch");
|
||||
}
|
||||
}
|
||||
else {
|
||||
// The cert chain doesn't look like a self-signed cert or we don't have
|
||||
// a certificate pinned, so re-throw the original validation error.
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
HostnameVerifier hv = new HostnameVerifier() {
|
||||
public boolean verify(String hostname, SSLSession session) {
|
||||
try {
|
||||
Certificate[] certificates = session.getPeerCertificates();
|
||||
if (certificates.length == 1 && certificates[0].equals(NvHTTP.this.serverCert)) {
|
||||
// Allow any hostname if it's our pinned cert
|
||||
return true;
|
||||
}
|
||||
} catch (SSLPeerUnverifiedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
// Fall back to default HostnameVerifier for validating CA-issued certs
|
||||
return HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session);
|
||||
}
|
||||
};
|
||||
|
||||
httpClient = new OkHttpClient.Builder()
|
||||
.connectionPool(new ConnectionPool(0, 1, TimeUnit.MILLISECONDS))
|
||||
.hostnameVerifier(hv)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.connectTimeout(CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
httpClientWithReadTimeout = httpClient.newBuilder()
|
||||
.readTimeout(READ_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
public NvHTTP(String address, String uniqueId, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException {
|
||||
// Use the same UID for all Moonlight clients so we can quit games
|
||||
// started by other Moonlight clients.
|
||||
this.uniqueId = "0123456789ABCDEF";
|
||||
|
||||
this.serverCert = serverCert;
|
||||
|
||||
initializeHttpState(cryptoProvider);
|
||||
|
||||
try {
|
||||
// The URI constructor takes care of escaping IPv6 literals
|
||||
this.baseUrlHttps = new URI("https", null, address, HTTPS_PORT, null, null, null).toString();
|
||||
this.baseUrlHttp = new URI("http", null, address, HTTP_PORT, null, null, null).toString();
|
||||
} catch (URISyntaxException e) {
|
||||
// Encapsulate URISyntaxException into IOException for callers to handle more easily
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
this.pm = new PairingManager(this, cryptoProvider);
|
||||
}
|
||||
|
||||
String buildUniqueIdUuidString() {
|
||||
return "uniqueid="+uniqueId+"&uuid="+UUID.randomUUID();
|
||||
}
|
||||
|
||||
static String getXmlString(Reader r, String tagname) throws XmlPullParserException, IOException {
|
||||
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
XmlPullParser xpp = factory.newPullParser();
|
||||
|
||||
xpp.setInput(r);
|
||||
int eventType = xpp.getEventType();
|
||||
Stack<String> currentTag = new Stack<String>();
|
||||
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
switch (eventType) {
|
||||
case (XmlPullParser.START_TAG):
|
||||
if (xpp.getName().equals("root")) {
|
||||
verifyResponseStatus(xpp);
|
||||
}
|
||||
currentTag.push(xpp.getName());
|
||||
break;
|
||||
case (XmlPullParser.END_TAG):
|
||||
currentTag.pop();
|
||||
break;
|
||||
case (XmlPullParser.TEXT):
|
||||
if (currentTag.peek().equals(tagname)) {
|
||||
return xpp.getText().trim();
|
||||
}
|
||||
break;
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String getXmlString(String str, String tagname) throws XmlPullParserException, IOException {
|
||||
return getXmlString(new StringReader(str), tagname);
|
||||
}
|
||||
|
||||
private static void verifyResponseStatus(XmlPullParser xpp) throws GfeHttpResponseException {
|
||||
// We use Long.parseLong() because in rare cases GFE can send back a status code of
|
||||
// 0xFFFFFFFF, which will cause Integer.parseInt() to throw a NumberFormatException due
|
||||
// to exceeding Integer.MAX_VALUE. We'll get the desired error code of -1 by just casting
|
||||
// the resulting long into an int.
|
||||
int statusCode = (int)Long.parseLong(xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_code"));
|
||||
if (statusCode != 200) {
|
||||
String statusMsg = xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_message");
|
||||
if (statusCode == -1 && "Invalid".equals(statusMsg)) {
|
||||
// Special case handling an audio capture error which GFE doesn't
|
||||
// provide any useful status message for.
|
||||
statusCode = 418;
|
||||
statusMsg = "Missing audio capture device. Reinstall GeForce Experience.";
|
||||
}
|
||||
throw new GfeHttpResponseException(statusCode, statusMsg);
|
||||
}
|
||||
}
|
||||
|
||||
public String getServerInfo() throws IOException, XmlPullParserException {
|
||||
String resp;
|
||||
|
||||
//
|
||||
// TODO: Shield Hub uses HTTP for this and is able to get an accurate PairStatus with HTTP.
|
||||
// For some reason, we always see PairStatus is 0 over HTTP and only 1 over HTTPS. It looks
|
||||
// like there are extra request headers required to make this stuff work over HTTP.
|
||||
//
|
||||
|
||||
// When we have a pinned cert, use HTTPS to fetch serverinfo and fall back on cert mismatch
|
||||
if (serverCert != null) {
|
||||
try {
|
||||
try {
|
||||
resp = openHttpConnectionToString(baseUrlHttps + "/serverinfo?"+buildUniqueIdUuidString(), true);
|
||||
} catch (SSLHandshakeException e) {
|
||||
// Detect if we failed due to a server cert mismatch
|
||||
if (e.getCause() instanceof CertificateException) {
|
||||
// Jump to the GfeHttpResponseException exception handler to retry
|
||||
// over HTTP which will allow us to pair again to update the cert
|
||||
throw new GfeHttpResponseException(401, "Server certificate mismatch");
|
||||
}
|
||||
else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// This will throw an exception if the request came back with a failure status.
|
||||
// We want this because it will throw us into the HTTP case if the client is unpaired.
|
||||
getServerVersion(resp);
|
||||
}
|
||||
catch (GfeHttpResponseException e) {
|
||||
if (e.getErrorCode() == 401) {
|
||||
// Cert validation error - fall back to HTTP
|
||||
return openHttpConnectionToString(baseUrlHttp + "/serverinfo", true);
|
||||
}
|
||||
|
||||
// If it's not a cert validation error, throw it
|
||||
throw e;
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
else {
|
||||
// No pinned cert, so use HTTP
|
||||
return openHttpConnectionToString(baseUrlHttp + "/serverinfo", true);
|
||||
}
|
||||
}
|
||||
|
||||
public ComputerDetails getComputerDetails() throws IOException, XmlPullParserException {
|
||||
ComputerDetails details = new ComputerDetails();
|
||||
String serverInfo = getServerInfo();
|
||||
|
||||
details.name = getXmlString(serverInfo, "hostname");
|
||||
if (details.name == null || details.name.isEmpty()) {
|
||||
details.name = "UNKNOWN";
|
||||
}
|
||||
|
||||
details.uuid = getXmlString(serverInfo, "uniqueid");
|
||||
details.macAddress = getXmlString(serverInfo, "mac");
|
||||
details.localAddress = getXmlString(serverInfo, "LocalIP");
|
||||
|
||||
// This may be null, but that's okay
|
||||
details.remoteAddress = getXmlString(serverInfo, "ExternalIP");
|
||||
|
||||
// This has some extra logic to always report unpaired if the pinned cert isn't there
|
||||
details.pairState = getPairState(serverInfo);
|
||||
|
||||
details.runningGameId = getCurrentGame(serverInfo);
|
||||
|
||||
// We could reach it so it's online
|
||||
details.state = ComputerDetails.State.ONLINE;
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
// This hack is Android-specific but we do it on all platforms
|
||||
// because it doesn't really matter
|
||||
private OkHttpClient performAndroidTlsHack(OkHttpClient client) {
|
||||
// Doing this each time we create a socket is required
|
||||
// to avoid the SSLv3 fallback that causes connection failures
|
||||
try {
|
||||
SSLContext sc = SSLContext.getInstance("TLS");
|
||||
sc.init(new KeyManager[] { keyManager }, new TrustManager[] { trustManager }, new SecureRandom());
|
||||
|
||||
// TLS 1.2 is not enabled by default prior to Android 5.0, so we'll need a custom
|
||||
// SSLSocketFactory in order to connect to GFE 3.20.4 which requires TLSv1.2 or later.
|
||||
// We don't just always use TLSv12SocketFactory because explicitly specifying TLS versions
|
||||
// prevents later TLS versions from being negotiated even if client and server otherwise
|
||||
// support them.
|
||||
return client.newBuilder().sslSocketFactory(
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ?
|
||||
sc.getSocketFactory() : new TLSv12SocketFactory(sc),
|
||||
trustManager).build();
|
||||
} catch (NoSuchAlgorithmException | KeyManagementException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Read timeout should be enabled for any HTTP query that requires no outside action
|
||||
// on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit.
|
||||
// The initial pair query does require outside action (user entering a PIN) but subsequent pairing
|
||||
// queries do not.
|
||||
private ResponseBody openHttpConnection(String url, boolean enableReadTimeout) throws IOException {
|
||||
Request request = new Request.Builder().url(url).get().build();
|
||||
Response response;
|
||||
|
||||
if (enableReadTimeout) {
|
||||
response = performAndroidTlsHack(httpClientWithReadTimeout).newCall(request).execute();
|
||||
}
|
||||
else {
|
||||
response = performAndroidTlsHack(httpClient).newCall(request).execute();
|
||||
}
|
||||
|
||||
ResponseBody body = response.body();
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// Unsuccessful, so close the response body
|
||||
if (body != null) {
|
||||
body.close();
|
||||
}
|
||||
|
||||
if (response.code() == 404) {
|
||||
throw new FileNotFoundException(url);
|
||||
}
|
||||
else {
|
||||
throw new GfeHttpResponseException(response.code(), response.message());
|
||||
}
|
||||
}
|
||||
|
||||
String openHttpConnectionToString(String url, boolean enableReadTimeout) throws IOException {
|
||||
try {
|
||||
if (verbose) {
|
||||
LimeLog.info("Requesting URL: "+url);
|
||||
}
|
||||
|
||||
ResponseBody resp = openHttpConnection(url, enableReadTimeout);
|
||||
String respString = resp.string();
|
||||
resp.close();
|
||||
|
||||
if (verbose) {
|
||||
LimeLog.info(url+" -> "+respString);
|
||||
}
|
||||
|
||||
return respString;
|
||||
} catch (IOException e) {
|
||||
if (verbose) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public String getServerVersion(String serverInfo) throws XmlPullParserException, IOException {
|
||||
return getXmlString(serverInfo, "appversion");
|
||||
}
|
||||
|
||||
public PairingManager.PairState getPairState() throws IOException, XmlPullParserException {
|
||||
return getPairState(getServerInfo());
|
||||
}
|
||||
|
||||
public PairingManager.PairState getPairState(String serverInfo) throws IOException, XmlPullParserException {
|
||||
if (!NvHTTP.getXmlString(serverInfo, "PairStatus").equals("1")) {
|
||||
return PairState.NOT_PAIRED;
|
||||
}
|
||||
|
||||
return PairState.PAIRED;
|
||||
}
|
||||
|
||||
public long getMaxLumaPixelsH264(String serverInfo) throws XmlPullParserException, IOException {
|
||||
String str = getXmlString(serverInfo, "MaxLumaPixelsH264");
|
||||
if (str != null) {
|
||||
return Long.parseLong(str);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public long getMaxLumaPixelsHEVC(String serverInfo) throws XmlPullParserException, IOException {
|
||||
String str = getXmlString(serverInfo, "MaxLumaPixelsHEVC");
|
||||
if (str != null) {
|
||||
return Long.parseLong(str);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Possible meaning of bits
|
||||
// Bit 0: H.264 Baseline
|
||||
// Bit 1: H.264 High
|
||||
// ----
|
||||
// Bit 8: HEVC Main
|
||||
// Bit 9: HEVC Main10
|
||||
// Bit 10: HEVC Main10 4:4:4
|
||||
// Bit 11: ???
|
||||
public long getServerCodecModeSupport(String serverInfo) throws XmlPullParserException, IOException {
|
||||
String str = getXmlString(serverInfo, "ServerCodecModeSupport");
|
||||
if (str != null) {
|
||||
return Long.parseLong(str);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public String getGpuType(String serverInfo) throws XmlPullParserException, IOException {
|
||||
return getXmlString(serverInfo, "gputype");
|
||||
}
|
||||
|
||||
public String getGfeVersion(String serverInfo) throws XmlPullParserException, IOException {
|
||||
return getXmlString(serverInfo, "GfeVersion");
|
||||
}
|
||||
|
||||
public boolean supports4K(String serverInfo) throws XmlPullParserException, IOException {
|
||||
// Only allow 4K on GFE 3.x
|
||||
String gfeVersionStr = getXmlString(serverInfo, "GfeVersion");
|
||||
if (gfeVersionStr == null || gfeVersionStr.startsWith("2.")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public int getCurrentGame(String serverInfo) throws IOException, XmlPullParserException {
|
||||
// GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer
|
||||
// has the semantics that its name would indicate. To contain the effects of this change as much
|
||||
// as possible, we'll force the current game to zero if the server isn't in a streaming session.
|
||||
String serverState = getXmlString(serverInfo, "state");
|
||||
if (serverState != null && serverState.endsWith("_SERVER_BUSY")) {
|
||||
String game = getXmlString(serverInfo, "currentgame");
|
||||
return Integer.parseInt(game);
|
||||
}
|
||||
else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public NvApp getAppById(int appId) throws IOException, XmlPullParserException {
|
||||
LinkedList<NvApp> appList = getAppList();
|
||||
for (NvApp appFromList : appList) {
|
||||
if (appFromList.getAppId() == appId) {
|
||||
return appFromList;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* NOTE: Only use this function if you know what you're doing.
|
||||
* It's totally valid to have two apps named the same thing,
|
||||
* or even nothing at all! Look apps up by ID if at all possible
|
||||
* using the above function */
|
||||
public NvApp getAppByName(String appName) throws IOException, XmlPullParserException {
|
||||
LinkedList<NvApp> appList = getAppList();
|
||||
for (NvApp appFromList : appList) {
|
||||
if (appFromList.getAppName().equalsIgnoreCase(appName)) {
|
||||
return appFromList;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public PairingManager getPairingManager() {
|
||||
return pm;
|
||||
}
|
||||
|
||||
public static LinkedList<NvApp> getAppListByReader(Reader r) throws XmlPullParserException, IOException {
|
||||
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
XmlPullParser xpp = factory.newPullParser();
|
||||
|
||||
xpp.setInput(r);
|
||||
int eventType = xpp.getEventType();
|
||||
LinkedList<NvApp> appList = new LinkedList<NvApp>();
|
||||
Stack<String> currentTag = new Stack<String>();
|
||||
boolean rootTerminated = false;
|
||||
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
switch (eventType) {
|
||||
case (XmlPullParser.START_TAG):
|
||||
if (xpp.getName().equals("root")) {
|
||||
verifyResponseStatus(xpp);
|
||||
}
|
||||
currentTag.push(xpp.getName());
|
||||
if (xpp.getName().equals("App")) {
|
||||
appList.addLast(new NvApp());
|
||||
}
|
||||
break;
|
||||
case (XmlPullParser.END_TAG):
|
||||
currentTag.pop();
|
||||
if (xpp.getName().equals("root")) {
|
||||
rootTerminated = true;
|
||||
}
|
||||
break;
|
||||
case (XmlPullParser.TEXT):
|
||||
NvApp app = appList.getLast();
|
||||
if (currentTag.peek().equals("AppTitle")) {
|
||||
app.setAppName(xpp.getText().trim());
|
||||
} else if (currentTag.peek().equals("ID")) {
|
||||
app.setAppId(xpp.getText().trim());
|
||||
} else if (currentTag.peek().equals("IsHdrSupported")) {
|
||||
app.setHdrSupported(xpp.getText().trim().equals("1"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
eventType = xpp.next();
|
||||
}
|
||||
|
||||
// Throw a malformed XML exception if we've not seen the root tag ended
|
||||
if (!rootTerminated) {
|
||||
throw new XmlPullParserException("Malformed XML: Root tag was not terminated");
|
||||
}
|
||||
|
||||
// Ensure that all apps in the list are initialized
|
||||
ListIterator<NvApp> i = appList.listIterator();
|
||||
while (i.hasNext()) {
|
||||
NvApp app = i.next();
|
||||
|
||||
// Remove uninitialized apps
|
||||
if (!app.isInitialized()) {
|
||||
LimeLog.warning("GFE returned incomplete app: "+app.getAppId()+" "+app.getAppName());
|
||||
i.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return appList;
|
||||
}
|
||||
|
||||
public String getAppListRaw() throws MalformedURLException, IOException {
|
||||
return openHttpConnectionToString(baseUrlHttps + "/applist?"+buildUniqueIdUuidString(), true);
|
||||
}
|
||||
|
||||
public LinkedList<NvApp> getAppList() throws GfeHttpResponseException, IOException, XmlPullParserException {
|
||||
if (verbose) {
|
||||
// Use the raw function so the app list is printed
|
||||
return getAppListByReader(new StringReader(getAppListRaw()));
|
||||
}
|
||||
else {
|
||||
ResponseBody resp = openHttpConnection(baseUrlHttps + "/applist?" + buildUniqueIdUuidString(), true);
|
||||
LinkedList<NvApp> appList = getAppListByReader(new InputStreamReader(resp.byteStream()));
|
||||
resp.close();
|
||||
return appList;
|
||||
}
|
||||
}
|
||||
|
||||
public void unpair() throws IOException {
|
||||
openHttpConnectionToString(baseUrlHttp + "/unpair?"+buildUniqueIdUuidString(), true);
|
||||
}
|
||||
|
||||
public InputStream getBoxArt(NvApp app) throws IOException {
|
||||
ResponseBody resp = openHttpConnection(baseUrlHttps + "/appasset?"+ buildUniqueIdUuidString() +
|
||||
"&appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0", true);
|
||||
return resp.byteStream();
|
||||
}
|
||||
|
||||
public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException {
|
||||
return getServerAppVersionQuad(serverInfo)[0];
|
||||
}
|
||||
|
||||
public int[] getServerAppVersionQuad(String serverInfo) throws XmlPullParserException, IOException {
|
||||
String serverVersion = getServerVersion(serverInfo);
|
||||
if (serverVersion == null) {
|
||||
throw new IllegalArgumentException("Missing server version field");
|
||||
}
|
||||
String[] serverVersionSplit = serverVersion.split("\\.");
|
||||
if (serverVersionSplit.length != 4) {
|
||||
throw new IllegalArgumentException("Malformed server version field: "+serverVersion);
|
||||
}
|
||||
int[] ret = new int[serverVersionSplit.length];
|
||||
for (int i = 0; i < ret.length; i++) {
|
||||
ret[i] = Integer.parseInt(serverVersionSplit[i]);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
char[] hexChars = new char[bytes.length * 2];
|
||||
for ( int j = 0; j < bytes.length; j++ ) {
|
||||
int v = bytes[j] & 0xFF;
|
||||
hexChars[j * 2] = hexArray[v >>> 4];
|
||||
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
|
||||
}
|
||||
return new String(hexChars);
|
||||
}
|
||||
|
||||
public boolean launchApp(ConnectionContext context, int appId, boolean enableHdr) throws IOException, XmlPullParserException {
|
||||
// Using an FPS value over 60 causes SOPS to default to 720p60,
|
||||
// so force it to 0 to ensure the correct resolution is set. We
|
||||
// used to use 60 here but that locked the frame rate to 60 FPS
|
||||
// on GFE 3.20.3.
|
||||
int fps = context.streamConfig.getLaunchRefreshRate() > 60 ? 0 : context.streamConfig.getLaunchRefreshRate();
|
||||
|
||||
// Using an unsupported resolution (not 720p, 1080p, or 4K) causes
|
||||
// GFE to force SOPS to 720p60. This is fine for < 720p resolutions like
|
||||
// 360p or 480p, but it is not ideal for 1440p and other resolutions.
|
||||
// When we detect an unsupported resolution, disable SOPS unless it's under 720p.
|
||||
// FIXME: Detect support resolutions using the serverinfo response, not a hardcoded list
|
||||
boolean enableSops = context.streamConfig.getSops();
|
||||
if (context.negotiatedWidth * context.negotiatedHeight > 1280 * 720 &&
|
||||
context.negotiatedWidth * context.negotiatedHeight != 1920 * 1080 &&
|
||||
context.negotiatedWidth * context.negotiatedHeight != 3840 * 2160) {
|
||||
LimeLog.info("Disabling SOPS due to non-standard resolution: "+context.negotiatedWidth+"x"+context.negotiatedHeight);
|
||||
enableSops = false;
|
||||
}
|
||||
|
||||
String xmlStr = openHttpConnectionToString(baseUrlHttps +
|
||||
"/launch?" + buildUniqueIdUuidString() +
|
||||
"&appid=" + appId +
|
||||
"&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + fps +
|
||||
"&additionalStates=1&sops=" + (enableSops ? 1 : 0) +
|
||||
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
|
||||
"&rikeyid="+context.riKeyId +
|
||||
(!enableHdr ? "" : "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0") +
|
||||
"&localAudioPlayMode=" + (context.streamConfig.getPlayLocalAudio() ? 1 : 0) +
|
||||
"&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo() +
|
||||
(context.streamConfig.getAttachedGamepadMask() != 0 ? "&remoteControllersBitmap=" + context.streamConfig.getAttachedGamepadMask() : "") +
|
||||
(context.streamConfig.getAttachedGamepadMask() != 0 ? "&gcmap=" + context.streamConfig.getAttachedGamepadMask() : ""),
|
||||
false);
|
||||
String gameSession = getXmlString(xmlStr, "gamesession");
|
||||
return gameSession != null && !gameSession.equals("0");
|
||||
}
|
||||
|
||||
public boolean resumeApp(ConnectionContext context) throws IOException, XmlPullParserException {
|
||||
String xmlStr = openHttpConnectionToString(baseUrlHttps + "/resume?" + buildUniqueIdUuidString() +
|
||||
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
|
||||
"&rikeyid="+context.riKeyId +
|
||||
"&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo(),
|
||||
false);
|
||||
String resume = getXmlString(xmlStr, "resume");
|
||||
return Integer.parseInt(resume) != 0;
|
||||
}
|
||||
|
||||
public boolean quitApp() throws IOException, XmlPullParserException {
|
||||
String xmlStr = openHttpConnectionToString(baseUrlHttps + "/cancel?" + buildUniqueIdUuidString(), false);
|
||||
String cancel = getXmlString(xmlStr, "cancel");
|
||||
if (Integer.parseInt(cancel) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Newer GFE versions will just return success even if quitting fails
|
||||
// if we're not the original requestor.
|
||||
if (getCurrentGame(getServerInfo()) != 0) {
|
||||
// Generate a synthetic GfeResponseException letting the caller know
|
||||
// that they can't kill someone else's stream.
|
||||
throw new GfeHttpResponseException(599, "");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Based on example code from https://blog.dev-area.net/2015/08/13/android-4-1-enable-tls-1-1-and-tls-1-2/
|
||||
private static class TLSv12SocketFactory extends SSLSocketFactory {
|
||||
private SSLSocketFactory internalSSLSocketFactory;
|
||||
|
||||
public TLSv12SocketFactory(SSLContext context) {
|
||||
internalSSLSocketFactory = context.getSocketFactory();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getDefaultCipherSuites() {
|
||||
return internalSSLSocketFactory.getDefaultCipherSuites();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getSupportedCipherSuites() {
|
||||
return internalSSLSocketFactory.getSupportedCipherSuites();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket() throws IOException {
|
||||
return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
|
||||
return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port) throws IOException {
|
||||
return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(host, port));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
|
||||
return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress host, int port) throws IOException {
|
||||
return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(host, port));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
|
||||
return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort));
|
||||
}
|
||||
|
||||
private Socket enableTLSv12OnSocket(Socket socket) {
|
||||
if (socket instanceof SSLSocket) {
|
||||
// TLS 1.2 is not enabled by default prior to Android 5.0. We must enable it
|
||||
// explicitly to ensure we can communicate with GFE 3.20.4 which blocks TLS 1.0.
|
||||
((SSLSocket)socket).setEnabledProtocols(new String[] {"TLSv1.2"});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package com.limelight.nvstream.http;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
|
||||
import java.security.cert.Certificate;
|
||||
import java.io.*;
|
||||
import java.security.*;
|
||||
import java.security.cert.*;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
public class PairingManager {
|
||||
|
||||
private NvHTTP http;
|
||||
|
||||
private PrivateKey pk;
|
||||
private X509Certificate cert;
|
||||
private SecretKey aesKey;
|
||||
private byte[] pemCertBytes;
|
||||
|
||||
private X509Certificate serverCert;
|
||||
|
||||
public enum PairState {
|
||||
NOT_PAIRED,
|
||||
PAIRED,
|
||||
PIN_WRONG,
|
||||
FAILED,
|
||||
ALREADY_IN_PROGRESS
|
||||
}
|
||||
|
||||
public PairingManager(NvHTTP http, LimelightCryptoProvider cryptoProvider) {
|
||||
this.http = http;
|
||||
this.cert = cryptoProvider.getClientCertificate();
|
||||
this.pemCertBytes = cryptoProvider.getPemEncodedClientCertificate();
|
||||
this.pk = cryptoProvider.getClientPrivateKey();
|
||||
}
|
||||
|
||||
final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
char[] hexChars = new char[bytes.length * 2];
|
||||
for ( int j = 0; j < bytes.length; j++ ) {
|
||||
int v = bytes[j] & 0xFF;
|
||||
hexChars[j * 2] = hexArray[v >>> 4];
|
||||
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
|
||||
}
|
||||
return new String(hexChars);
|
||||
}
|
||||
|
||||
private static byte[] hexToBytes(String s) {
|
||||
int len = s.length();
|
||||
if (len % 2 != 0) {
|
||||
throw new IllegalArgumentException("Illegal string length: "+len);
|
||||
}
|
||||
|
||||
byte[] data = new byte[len / 2];
|
||||
for (int i = 0; i < len; i += 2) {
|
||||
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
|
||||
+ Character.digit(s.charAt(i+1), 16));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private X509Certificate extractPlainCert(String text) throws XmlPullParserException, IOException
|
||||
{
|
||||
String certText = NvHTTP.getXmlString(text, "plaincert");
|
||||
if (certText != null) {
|
||||
byte[] certBytes = hexToBytes(certText);
|
||||
|
||||
try {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
return (X509Certificate)cf.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
} catch (CertificateException e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] generateRandomBytes(int length)
|
||||
{
|
||||
byte[] rand = new byte[length];
|
||||
new SecureRandom().nextBytes(rand);
|
||||
return rand;
|
||||
}
|
||||
|
||||
private static byte[] saltPin(byte[] salt, String pin) throws UnsupportedEncodingException {
|
||||
byte[] saltedPin = new byte[salt.length + pin.length()];
|
||||
System.arraycopy(salt, 0, saltedPin, 0, salt.length);
|
||||
System.arraycopy(pin.getBytes("UTF-8"), 0, saltedPin, salt.length, pin.length());
|
||||
return saltedPin;
|
||||
}
|
||||
|
||||
private static boolean verifySignature(byte[] data, byte[] signature, Certificate cert) {
|
||||
try {
|
||||
Signature sig = Signature.getInstance("SHA256withRSA");
|
||||
sig.initVerify(cert.getPublicKey());
|
||||
sig.update(data);
|
||||
return sig.verify(signature);
|
||||
} catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] signData(byte[] data, PrivateKey key) {
|
||||
try {
|
||||
Signature sig = Signature.getInstance("SHA256withRSA");
|
||||
sig.initSign(key);
|
||||
sig.update(data);
|
||||
byte[] signature = new byte[256];
|
||||
sig.sign(signature, 0, signature.length);
|
||||
return signature;
|
||||
} catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] decryptAes(byte[] encryptedData, SecretKey secretKey) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
|
||||
|
||||
int blockRoundedSize = ((encryptedData.length + 15) / 16) * 16;
|
||||
byte[] blockRoundedEncrypted = Arrays.copyOf(encryptedData, blockRoundedSize);
|
||||
byte[] fullDecrypted = new byte[blockRoundedSize];
|
||||
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey);
|
||||
cipher.doFinal(blockRoundedEncrypted, 0,
|
||||
blockRoundedSize, fullDecrypted);
|
||||
return fullDecrypted;
|
||||
} catch (GeneralSecurityException e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encryptAes(byte[] data, SecretKey secretKey) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
|
||||
|
||||
int blockRoundedSize = ((data.length + 15) / 16) * 16;
|
||||
byte[] blockRoundedData = Arrays.copyOf(data, blockRoundedSize);
|
||||
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
|
||||
return cipher.doFinal(blockRoundedData);
|
||||
} catch (GeneralSecurityException e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey generateAesKey(PairingHashAlgorithm hashAlgo, byte[] keyData) {
|
||||
byte[] aesTruncated = Arrays.copyOf(hashAlgo.hashData(keyData), 16);
|
||||
return new SecretKeySpec(aesTruncated, "AES");
|
||||
}
|
||||
|
||||
private static byte[] concatBytes(byte[] a, byte[] b) {
|
||||
byte[] c = new byte[a.length + b.length];
|
||||
System.arraycopy(a, 0, c, 0, a.length);
|
||||
System.arraycopy(b, 0, c, a.length, b.length);
|
||||
return c;
|
||||
}
|
||||
|
||||
public static String generatePinString() {
|
||||
SecureRandom r = new SecureRandom();
|
||||
return String.format((Locale)null, "%d%d%d%d",
|
||||
r.nextInt(10), r.nextInt(10),
|
||||
r.nextInt(10), r.nextInt(10));
|
||||
}
|
||||
|
||||
public X509Certificate getPairedCert() {
|
||||
return serverCert;
|
||||
}
|
||||
|
||||
public PairState pair(String serverInfo, String pin) throws IOException, XmlPullParserException {
|
||||
PairingHashAlgorithm hashAlgo;
|
||||
|
||||
int serverMajorVersion = http.getServerMajorVersion(serverInfo);
|
||||
LimeLog.info("Pairing with server generation: "+serverMajorVersion);
|
||||
if (serverMajorVersion >= 7) {
|
||||
// Gen 7+ uses SHA-256 hashing
|
||||
hashAlgo = new Sha256PairingHash();
|
||||
}
|
||||
else {
|
||||
// Prior to Gen 7, SHA-1 is used
|
||||
hashAlgo = new Sha1PairingHash();
|
||||
}
|
||||
|
||||
// Generate a salt for hashing the PIN
|
||||
byte[] salt = generateRandomBytes(16);
|
||||
|
||||
// Combine the salt and pin, then create an AES key from them
|
||||
byte[] saltAndPin = saltPin(salt, pin);
|
||||
aesKey = generateAesKey(hashAlgo, saltAndPin);
|
||||
|
||||
// Send the salt and get the server cert. This doesn't have a read timeout
|
||||
// because the user must enter the PIN before the server responds
|
||||
String getCert = http.openHttpConnectionToString(http.baseUrlHttp +
|
||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&phrase=getservercert&salt="+
|
||||
bytesToHex(salt)+"&clientcert="+bytesToHex(pemCertBytes),
|
||||
false);
|
||||
if (!NvHTTP.getXmlString(getCert, "paired").equals("1")) {
|
||||
return PairState.FAILED;
|
||||
}
|
||||
|
||||
// Save this cert for retrieval later
|
||||
serverCert = extractPlainCert(getCert);
|
||||
if (serverCert == null) {
|
||||
// Attempting to pair while another device is pairing will cause GFE
|
||||
// to give an empty cert in the response.
|
||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
||||
return PairState.ALREADY_IN_PROGRESS;
|
||||
}
|
||||
|
||||
// Require this cert for TLS to this host
|
||||
http.setServerCert(serverCert);
|
||||
|
||||
// Generate a random challenge and encrypt it with our AES key
|
||||
byte[] randomChallenge = generateRandomBytes(16);
|
||||
byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey);
|
||||
|
||||
// Send the encrypted challenge to the server
|
||||
String challengeResp = http.openHttpConnectionToString(http.baseUrlHttp +
|
||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&clientchallenge="+bytesToHex(encryptedChallenge),
|
||||
true);
|
||||
if (!NvHTTP.getXmlString(challengeResp, "paired").equals("1")) {
|
||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
||||
return PairState.FAILED;
|
||||
}
|
||||
|
||||
// Decode the server's response and subsequent challenge
|
||||
byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse"));
|
||||
byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey);
|
||||
|
||||
byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength());
|
||||
byte[] serverChallenge = Arrays.copyOfRange(decServerChallengeResponse, hashAlgo.getHashLength(), hashAlgo.getHashLength() + 16);
|
||||
|
||||
// Using another 16 bytes secret, compute a challenge response hash using the secret, our cert sig, and the challenge
|
||||
byte[] clientSecret = generateRandomBytes(16);
|
||||
byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret));
|
||||
byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey);
|
||||
String secretResp = http.openHttpConnectionToString(http.baseUrlHttp +
|
||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&serverchallengeresp="+bytesToHex(challengeRespEncrypted),
|
||||
true);
|
||||
if (!NvHTTP.getXmlString(secretResp, "paired").equals("1")) {
|
||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
||||
return PairState.FAILED;
|
||||
}
|
||||
|
||||
// Get the server's signed secret
|
||||
byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret"));
|
||||
byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16);
|
||||
byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, 272);
|
||||
|
||||
// Ensure the authenticity of the data
|
||||
if (!verifySignature(serverSecret, serverSignature, serverCert)) {
|
||||
// Cancel the pairing process
|
||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
||||
|
||||
// Looks like a MITM
|
||||
return PairState.FAILED;
|
||||
}
|
||||
|
||||
// Ensure the server challenge matched what we expected (aka the PIN was correct)
|
||||
byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret));
|
||||
if (!Arrays.equals(serverChallengeRespHash, serverResponse)) {
|
||||
// Cancel the pairing process
|
||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
||||
|
||||
// Probably got the wrong PIN
|
||||
return PairState.PIN_WRONG;
|
||||
}
|
||||
|
||||
// Send the server our signed secret
|
||||
byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk));
|
||||
String clientSecretResp = http.openHttpConnectionToString(http.baseUrlHttp +
|
||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&clientpairingsecret="+bytesToHex(clientPairingSecret),
|
||||
true);
|
||||
if (!NvHTTP.getXmlString(clientSecretResp, "paired").equals("1")) {
|
||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
||||
return PairState.FAILED;
|
||||
}
|
||||
|
||||
// Do the initial challenge (seems neccessary for us to show as paired)
|
||||
String pairChallenge = http.openHttpConnectionToString(http.baseUrlHttps +
|
||||
"/pair?"+http.buildUniqueIdUuidString()+"&devicename=roth&updateState=1&phrase=pairchallenge", true);
|
||||
if (!NvHTTP.getXmlString(pairChallenge, "paired").equals("1")) {
|
||||
http.openHttpConnectionToString(http.baseUrlHttp + "/unpair?"+http.buildUniqueIdUuidString(), true);
|
||||
return PairState.FAILED;
|
||||
}
|
||||
|
||||
return PairState.PAIRED;
|
||||
}
|
||||
|
||||
private interface PairingHashAlgorithm {
|
||||
int getHashLength();
|
||||
byte[] hashData(byte[] data);
|
||||
}
|
||||
|
||||
private static class Sha1PairingHash implements PairingHashAlgorithm {
|
||||
public int getHashLength() {
|
||||
return 20;
|
||||
}
|
||||
|
||||
public byte[] hashData(byte[] data) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
return md.digest(data);
|
||||
}
|
||||
catch (NoSuchAlgorithmException e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class Sha256PairingHash implements PairingHashAlgorithm {
|
||||
public int getHashLength() {
|
||||
return 32;
|
||||
}
|
||||
|
||||
public byte[] hashData(byte[] data) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
return md.digest(data);
|
||||
}
|
||||
catch (NoSuchAlgorithmException e) {
|
||||
e.printStackTrace();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.limelight.nvstream.input;
|
||||
|
||||
public class ControllerPacket {
|
||||
public static final short A_FLAG = 0x1000;
|
||||
public static final short B_FLAG = 0x2000;
|
||||
public static final short X_FLAG = 0x4000;
|
||||
public static final short Y_FLAG = (short)0x8000;
|
||||
public static final short UP_FLAG = 0x0001;
|
||||
public static final short DOWN_FLAG = 0x0002;
|
||||
public static final short LEFT_FLAG = 0x0004;
|
||||
public static final short RIGHT_FLAG = 0x0008;
|
||||
public static final short LB_FLAG = 0x0100;
|
||||
public static final short RB_FLAG = 0x0200;
|
||||
public static final short PLAY_FLAG = 0x0010;
|
||||
public static final short BACK_FLAG = 0x0020;
|
||||
public static final short LS_CLK_FLAG = 0x0040;
|
||||
public static final short RS_CLK_FLAG = 0x0080;
|
||||
public static final short SPECIAL_BUTTON_FLAG = 0x0400;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.limelight.nvstream.input;
|
||||
|
||||
public class KeyboardPacket {
|
||||
public static final byte KEY_DOWN = 0x03;
|
||||
public static final byte KEY_UP = 0x04;
|
||||
|
||||
public static final byte MODIFIER_SHIFT = 0x01;
|
||||
public static final byte MODIFIER_CTRL = 0x02;
|
||||
public static final byte MODIFIER_ALT = 0x04;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.limelight.nvstream.input;
|
||||
|
||||
public class MouseButtonPacket {
|
||||
public static final byte PRESS_EVENT = 0x07;
|
||||
public static final byte RELEASE_EVENT = 0x08;
|
||||
|
||||
public static final byte BUTTON_LEFT = 0x01;
|
||||
public static final byte BUTTON_MIDDLE = 0x02;
|
||||
public static final byte BUTTON_RIGHT = 0x03;
|
||||
public static final byte BUTTON_X1 = 0x04;
|
||||
public static final byte BUTTON_X2 = 0x05;
|
||||
}
|
||||
305
app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java
Normal file
305
app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java
Normal file
@@ -0,0 +1,305 @@
|
||||
package com.limelight.nvstream.jni;
|
||||
|
||||
import com.limelight.nvstream.NvConnectionListener;
|
||||
import com.limelight.nvstream.av.audio.AudioRenderer;
|
||||
import com.limelight.nvstream.av.video.VideoDecoderRenderer;
|
||||
|
||||
public class MoonBridge {
|
||||
/* See documentation in Limelight.h for information about these functions and constants */
|
||||
|
||||
public static final AudioConfiguration AUDIO_CONFIGURATION_STEREO = new AudioConfiguration(2, 0x3);
|
||||
public static final AudioConfiguration AUDIO_CONFIGURATION_51_SURROUND = new AudioConfiguration(6, 0x3F);
|
||||
public static final AudioConfiguration AUDIO_CONFIGURATION_71_SURROUND = new AudioConfiguration(8, 0x63F);
|
||||
|
||||
public static final int VIDEO_FORMAT_H264 = 0x0001;
|
||||
public static final int VIDEO_FORMAT_H265 = 0x0100;
|
||||
public static final int VIDEO_FORMAT_H265_MAIN10 = 0x0200;
|
||||
|
||||
public static final int VIDEO_FORMAT_MASK_H264 = 0x00FF;
|
||||
public static final int VIDEO_FORMAT_MASK_H265 = 0xFF00;
|
||||
|
||||
public static final int BUFFER_TYPE_PICDATA = 0;
|
||||
public static final int BUFFER_TYPE_SPS = 1;
|
||||
public static final int BUFFER_TYPE_PPS = 2;
|
||||
public static final int BUFFER_TYPE_VPS = 3;
|
||||
|
||||
public static final int CAPABILITY_DIRECT_SUBMIT = 1;
|
||||
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC = 2;
|
||||
public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC = 4;
|
||||
|
||||
public static final int DR_OK = 0;
|
||||
public static final int DR_NEED_IDR = -1;
|
||||
|
||||
public static final int CONN_STATUS_OKAY = 0;
|
||||
public static final int CONN_STATUS_POOR = 1;
|
||||
|
||||
public static final int ML_ERROR_GRACEFUL_TERMINATION = 0;
|
||||
public static final int ML_ERROR_NO_VIDEO_TRAFFIC = -100;
|
||||
public static final int ML_ERROR_NO_VIDEO_FRAME = -101;
|
||||
|
||||
public static final int ML_PORT_INDEX_TCP_47984 = 0;
|
||||
public static final int ML_PORT_INDEX_TCP_47989 = 1;
|
||||
public static final int ML_PORT_INDEX_TCP_48010 = 2;
|
||||
public static final int ML_PORT_INDEX_UDP_47998 = 8;
|
||||
public static final int ML_PORT_INDEX_UDP_47999 = 9;
|
||||
public static final int ML_PORT_INDEX_UDP_48000 = 10;
|
||||
public static final int ML_PORT_INDEX_UDP_48010 = 11;
|
||||
|
||||
public static final int ML_PORT_FLAG_ALL = 0xFFFFFFFF;
|
||||
public static final int ML_PORT_FLAG_TCP_47984 = 0x0001;
|
||||
public static final int ML_PORT_FLAG_TCP_47989 = 0x0002;
|
||||
public static final int ML_PORT_FLAG_TCP_48010 = 0x0004;
|
||||
public static final int ML_PORT_FLAG_UDP_47998 = 0x0100;
|
||||
public static final int ML_PORT_FLAG_UDP_47999 = 0x0200;
|
||||
public static final int ML_PORT_FLAG_UDP_48000 = 0x0400;
|
||||
public static final int ML_PORT_FLAG_UDP_48010 = 0x0800;
|
||||
|
||||
public static final int ML_TEST_RESULT_INCONCLUSIVE = 0xFFFFFFFF;
|
||||
|
||||
private static AudioRenderer audioRenderer;
|
||||
private static VideoDecoderRenderer videoRenderer;
|
||||
private static NvConnectionListener connectionListener;
|
||||
|
||||
static {
|
||||
System.loadLibrary("moonlight-core");
|
||||
init();
|
||||
}
|
||||
|
||||
public static int CAPABILITY_SLICES_PER_FRAME(byte slices) {
|
||||
return slices << 24;
|
||||
}
|
||||
|
||||
public static class AudioConfiguration {
|
||||
public final int channelCount;
|
||||
public final int channelMask;
|
||||
|
||||
public AudioConfiguration(int channelCount, int channelMask) {
|
||||
this.channelCount = channelCount;
|
||||
this.channelMask = channelMask;
|
||||
}
|
||||
|
||||
// Creates an AudioConfiguration from the integer value returned by moonlight-common-c
|
||||
// See CHANNEL_COUNT_FROM_AUDIO_CONFIGURATION() and CHANNEL_MASK_FROM_AUDIO_CONFIGURATION()
|
||||
// in Limelight.h
|
||||
private AudioConfiguration(int audioConfiguration) {
|
||||
// Check the magic byte before decoding to make sure we got something that's actually
|
||||
// a MAKE_AUDIO_CONFIGURATION()-based value and not something else like an older version
|
||||
// hardcoded AUDIO_CONFIGURATION value from an earlier version of moonlight-common-c.
|
||||
if ((audioConfiguration & 0xFF) != 0xCA) {
|
||||
throw new IllegalArgumentException("Audio configuration has invalid magic byte!");
|
||||
}
|
||||
|
||||
this.channelCount = (audioConfiguration >> 8) & 0xFF;
|
||||
this.channelMask = (audioConfiguration >> 16) & 0xFFFF;
|
||||
}
|
||||
|
||||
// See SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION() in Limelight.h
|
||||
public int getSurroundAudioInfo() {
|
||||
return channelMask << 16 | channelCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj instanceof AudioConfiguration) {
|
||||
AudioConfiguration that = (AudioConfiguration)obj;
|
||||
return this.toInt() == that.toInt();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return toInt();
|
||||
}
|
||||
|
||||
// Returns the integer value expected by moonlight-common-c
|
||||
// See MAKE_AUDIO_CONFIGURATION() in Limelight.h
|
||||
public int toInt() {
|
||||
return ((channelMask) << 16) | (channelCount << 8) | 0xCA;
|
||||
}
|
||||
}
|
||||
|
||||
public static int bridgeDrSetup(int videoFormat, int width, int height, int redrawRate) {
|
||||
if (videoRenderer != null) {
|
||||
return videoRenderer.setup(videoFormat, width, height, redrawRate);
|
||||
}
|
||||
else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeDrStart() {
|
||||
if (videoRenderer != null) {
|
||||
videoRenderer.start();
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeDrStop() {
|
||||
if (videoRenderer != null) {
|
||||
videoRenderer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeDrCleanup() {
|
||||
if (videoRenderer != null) {
|
||||
videoRenderer.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
public static int bridgeDrSubmitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength,
|
||||
int decodeUnitType,
|
||||
int frameNumber, long receiveTimeMs) {
|
||||
if (videoRenderer != null) {
|
||||
return videoRenderer.submitDecodeUnit(decodeUnitData, decodeUnitLength,
|
||||
decodeUnitType, frameNumber, receiveTimeMs);
|
||||
}
|
||||
else {
|
||||
return DR_OK;
|
||||
}
|
||||
}
|
||||
|
||||
public static int bridgeArInit(int audioConfiguration, int sampleRate, int samplesPerFrame) {
|
||||
if (audioRenderer != null) {
|
||||
return audioRenderer.setup(new AudioConfiguration(audioConfiguration), sampleRate, samplesPerFrame);
|
||||
}
|
||||
else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeArStart() {
|
||||
if (audioRenderer != null) {
|
||||
audioRenderer.start();
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeArStop() {
|
||||
if (audioRenderer != null) {
|
||||
audioRenderer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeArCleanup() {
|
||||
if (audioRenderer != null) {
|
||||
audioRenderer.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeArPlaySample(short[] pcmData) {
|
||||
if (audioRenderer != null) {
|
||||
audioRenderer.playDecodedAudio(pcmData);
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeClStageStarting(int stage) {
|
||||
if (connectionListener != null) {
|
||||
connectionListener.stageStarting(getStageName(stage));
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeClStageComplete(int stage) {
|
||||
if (connectionListener != null) {
|
||||
connectionListener.stageComplete(getStageName(stage));
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeClStageFailed(int stage, int errorCode) {
|
||||
if (connectionListener != null) {
|
||||
connectionListener.stageFailed(getStageName(stage), getPortFlagsFromStage(stage), errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeClConnectionStarted() {
|
||||
if (connectionListener != null) {
|
||||
connectionListener.connectionStarted();
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeClConnectionTerminated(int errorCode) {
|
||||
if (connectionListener != null) {
|
||||
connectionListener.connectionTerminated(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeClRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) {
|
||||
if (connectionListener != null) {
|
||||
connectionListener.rumble(controllerNumber, lowFreqMotor, highFreqMotor);
|
||||
}
|
||||
}
|
||||
|
||||
public static void bridgeClConnectionStatusUpdate(int connectionStatus) {
|
||||
if (connectionListener != null) {
|
||||
connectionListener.connectionStatusUpdate(connectionStatus);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setupBridge(VideoDecoderRenderer videoRenderer, AudioRenderer audioRenderer, NvConnectionListener connectionListener) {
|
||||
MoonBridge.videoRenderer = videoRenderer;
|
||||
MoonBridge.audioRenderer = audioRenderer;
|
||||
MoonBridge.connectionListener = connectionListener;
|
||||
}
|
||||
|
||||
public static void cleanupBridge() {
|
||||
MoonBridge.videoRenderer = null;
|
||||
MoonBridge.audioRenderer = null;
|
||||
MoonBridge.connectionListener = null;
|
||||
}
|
||||
|
||||
public static native int startConnection(String address, String appVersion, String gfeVersion,
|
||||
int width, int height, int fps,
|
||||
int bitrate, int packetSize, int streamingRemotely,
|
||||
int audioConfiguration, boolean supportsHevc,
|
||||
boolean enableHdr,
|
||||
int hevcBitratePercentageMultiplier,
|
||||
int clientRefreshRateX100,
|
||||
byte[] riAesKey, byte[] riAesIv,
|
||||
int videoCapabilities);
|
||||
|
||||
public static native void stopConnection();
|
||||
|
||||
public static native void interruptConnection();
|
||||
|
||||
public static native void sendMouseMove(short deltaX, short deltaY);
|
||||
|
||||
public static native void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight);
|
||||
|
||||
public static native void sendMouseButton(byte buttonEvent, byte mouseButton);
|
||||
|
||||
public static native void sendMultiControllerInput(short controllerNumber,
|
||||
short activeGamepadMask, short buttonFlags,
|
||||
byte leftTrigger, byte rightTrigger,
|
||||
short leftStickX, short leftStickY,
|
||||
short rightStickX, short rightStickY);
|
||||
|
||||
public static native void sendControllerInput(short buttonFlags,
|
||||
byte leftTrigger, byte rightTrigger,
|
||||
short leftStickX, short leftStickY,
|
||||
short rightStickX, short rightStickY);
|
||||
|
||||
public static native void sendKeyboardInput(short keyMap, byte keyDirection, byte modifier);
|
||||
|
||||
public static native void sendMouseScroll(byte scrollClicks);
|
||||
|
||||
public static native void sendMouseHighResScroll(short scrollAmount);
|
||||
|
||||
public static native String getStageName(int stage);
|
||||
|
||||
public static native String findExternalAddressIP4(String stunHostName, int stunPort);
|
||||
|
||||
public static native int getPendingAudioDuration();
|
||||
|
||||
public static native int getPendingVideoFrames();
|
||||
|
||||
public static native int testClientConnectivity(String testServerHostName, int referencePort, int testFlags);
|
||||
|
||||
public static native int getPortFromPortFlagIndex(int portFlagIndex);
|
||||
|
||||
public static native String getProtocolFromPortFlagIndex(int portFlagIndex);
|
||||
|
||||
public static native int getPortFlagsFromStage(int stage);
|
||||
|
||||
public static native int getPortFlagsFromTerminationErrorCode(int errorCode);
|
||||
|
||||
public static native void init();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.limelight.nvstream.mdns;
|
||||
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
|
||||
public class MdnsComputer {
|
||||
private InetAddress localAddr;
|
||||
private Inet6Address v6Addr;
|
||||
private String name;
|
||||
|
||||
public MdnsComputer(String name, InetAddress localAddress, Inet6Address v6Addr) {
|
||||
this.name = name;
|
||||
this.localAddr = localAddress;
|
||||
this.v6Addr = v6Addr;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public InetAddress getLocalAddress() {
|
||||
return localAddr;
|
||||
}
|
||||
|
||||
public Inet6Address getIpv6Address() {
|
||||
return v6Addr;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return name.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o instanceof MdnsComputer) {
|
||||
MdnsComputer other = (MdnsComputer)o;
|
||||
|
||||
if (!other.name.equals(name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((other.localAddr != null && localAddr == null) ||
|
||||
(other.localAddr == null && localAddr != null) ||
|
||||
(other.localAddr != null && !other.localAddr.equals(localAddr))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((other.v6Addr != null && v6Addr == null) ||
|
||||
(other.v6Addr == null && v6Addr != null) ||
|
||||
(other.v6Addr != null && !other.v6Addr.equals(v6Addr))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "["+name+" - "+localAddr+" - "+v6Addr+"]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package com.limelight.nvstream.mdns;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import javax.jmdns.JmmDNS;
|
||||
import javax.jmdns.NetworkTopologyDiscovery;
|
||||
import javax.jmdns.ServiceEvent;
|
||||
import javax.jmdns.ServiceInfo;
|
||||
import javax.jmdns.ServiceListener;
|
||||
import javax.jmdns.impl.NetworkTopologyDiscoveryImpl;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
|
||||
public class MdnsDiscoveryAgent implements ServiceListener {
|
||||
public static final String SERVICE_TYPE = "_nvstream._tcp.local.";
|
||||
|
||||
private MdnsDiscoveryListener listener;
|
||||
private Thread discoveryThread;
|
||||
private HashMap<InetAddress, MdnsComputer> computers = new HashMap<InetAddress, MdnsComputer>();
|
||||
private HashSet<String> pendingResolution = new HashSet<String>();
|
||||
|
||||
// The resolver factory's instance member has a static lifetime which
|
||||
// means our ref count and listener must be static also.
|
||||
private static int resolverRefCount = 0;
|
||||
private static HashSet<ServiceListener> listeners = new HashSet<ServiceListener>();
|
||||
private static ServiceListener nvstreamListener = new ServiceListener() {
|
||||
@Override
|
||||
public void serviceAdded(ServiceEvent event) {
|
||||
HashSet<ServiceListener> localListeners;
|
||||
|
||||
// Copy the listener set into a new set so we can invoke
|
||||
// the callbacks without holding the listeners monitor the
|
||||
// whole time.
|
||||
synchronized (listeners) {
|
||||
localListeners = new HashSet<ServiceListener>(listeners);
|
||||
}
|
||||
|
||||
for (ServiceListener listener : localListeners) {
|
||||
listener.serviceAdded(event);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serviceRemoved(ServiceEvent event) {
|
||||
HashSet<ServiceListener> localListeners;
|
||||
|
||||
// Copy the listener set into a new set so we can invoke
|
||||
// the callbacks without holding the listeners monitor the
|
||||
// whole time.
|
||||
synchronized (listeners) {
|
||||
localListeners = new HashSet<ServiceListener>(listeners);
|
||||
}
|
||||
|
||||
for (ServiceListener listener : localListeners) {
|
||||
listener.serviceRemoved(event);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serviceResolved(ServiceEvent event) {
|
||||
HashSet<ServiceListener> localListeners;
|
||||
|
||||
// Copy the listener set into a new set so we can invoke
|
||||
// the callbacks without holding the listeners monitor the
|
||||
// whole time.
|
||||
synchronized (listeners) {
|
||||
localListeners = new HashSet<ServiceListener>(listeners);
|
||||
}
|
||||
|
||||
for (ServiceListener listener : localListeners) {
|
||||
listener.serviceResolved(event);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static class MyNetworkTopologyDiscovery extends NetworkTopologyDiscoveryImpl {
|
||||
@Override
|
||||
public boolean useInetAddress(NetworkInterface networkInterface, InetAddress interfaceAddress) {
|
||||
// This is an copy of jmDNS's implementation, except we omit the multicast check, since
|
||||
// it seems at least some devices lie about interfaces not supporting multicast when they really do.
|
||||
try {
|
||||
if (!networkInterface.isUp()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
if (!networkInterface.supportsMulticast()) {
|
||||
return false;
|
||||
}
|
||||
*/
|
||||
|
||||
if (networkInterface.isLoopback()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static {
|
||||
// Override jmDNS's default topology discovery class with ours
|
||||
NetworkTopologyDiscovery.Factory.setClassDelegate(new NetworkTopologyDiscovery.Factory.ClassDelegate() {
|
||||
@Override
|
||||
public NetworkTopologyDiscovery newNetworkTopologyDiscovery() {
|
||||
return new MyNetworkTopologyDiscovery();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static JmmDNS referenceResolver() {
|
||||
synchronized (MdnsDiscoveryAgent.class) {
|
||||
JmmDNS instance = JmmDNS.Factory.getInstance();
|
||||
if (++resolverRefCount == 1) {
|
||||
// This will cause the listener to be invoked for known hosts immediately.
|
||||
// JmDNS only supports one listener per service, so we have to do this here
|
||||
// with a static listener.
|
||||
instance.addServiceListener(SERVICE_TYPE, nvstreamListener);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
private static void dereferenceResolver() {
|
||||
synchronized (MdnsDiscoveryAgent.class) {
|
||||
if (--resolverRefCount == 0) {
|
||||
try {
|
||||
JmmDNS.Factory.close();
|
||||
} catch (IOException e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public MdnsDiscoveryAgent(MdnsDiscoveryListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
private void handleResolvedServiceInfo(ServiceInfo info) {
|
||||
synchronized (pendingResolution) {
|
||||
pendingResolution.remove(info.getName());
|
||||
}
|
||||
|
||||
try {
|
||||
handleServiceInfo(info);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
// Invalid DNS response
|
||||
LimeLog.info("mDNS: Invalid response for machine: "+info.getName());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private Inet6Address getLocalAddress(Inet6Address[] addresses) {
|
||||
for (Inet6Address addr : addresses) {
|
||||
if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) {
|
||||
return addr;
|
||||
}
|
||||
// fc00::/7 - ULAs
|
||||
else if ((addr.getAddress()[0] & 0xfe) == 0xfc) {
|
||||
return addr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Inet6Address getLinkLocalAddress(Inet6Address[] addresses) {
|
||||
for (Inet6Address addr : addresses) {
|
||||
if (addr.isLinkLocalAddress()) {
|
||||
LimeLog.info("Found link-local address: "+addr.getHostAddress());
|
||||
return addr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Inet6Address getBestIpv6Address(Inet6Address[] addresses) {
|
||||
// First try to find a link local address, so we can match the interface identifier
|
||||
// with a global address (this will work for SLAAC but not DHCPv6).
|
||||
Inet6Address linkLocalAddr = getLinkLocalAddress(addresses);
|
||||
|
||||
// We will try once to match a SLAAC interface suffix, then
|
||||
// pick the first matching address
|
||||
for (int tries = 0; tries < 2; tries++) {
|
||||
// We assume the addresses are already sorted in descending order
|
||||
// of preference from Bonjour.
|
||||
for (Inet6Address addr : addresses) {
|
||||
if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isLoopbackAddress()) {
|
||||
// Link-local, site-local, and loopback aren't global
|
||||
LimeLog.info("Ignoring non-global address: "+addr.getHostAddress());
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] addrBytes = addr.getAddress();
|
||||
|
||||
// 2002::/16
|
||||
if (addrBytes[0] == 0x20 && addrBytes[1] == 0x02) {
|
||||
// 6to4 has horrible performance
|
||||
LimeLog.info("Ignoring 6to4 address: "+addr.getHostAddress());
|
||||
continue;
|
||||
}
|
||||
// 2001::/32
|
||||
else if (addrBytes[0] == 0x20 && addrBytes[1] == 0x01 && addrBytes[2] == 0x00 && addrBytes[3] == 0x00) {
|
||||
// Teredo also has horrible performance
|
||||
LimeLog.info("Ignoring Teredo address: "+addr.getHostAddress());
|
||||
continue;
|
||||
}
|
||||
// fc00::/7
|
||||
else if ((addrBytes[0] & 0xfe) == 0xfc) {
|
||||
// ULAs aren't global
|
||||
LimeLog.info("Ignoring ULA: "+addr.getHostAddress());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compare the final 64-bit interface identifier and skip the address
|
||||
// if it doesn't match our link-local address.
|
||||
if (linkLocalAddr != null && tries == 0) {
|
||||
boolean matched = true;
|
||||
|
||||
for (int i = 8; i < 16; i++) {
|
||||
if (linkLocalAddr.getAddress()[i] != addr.getAddress()[i]) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
LimeLog.info("Ignoring non-matching global address: "+addr.getHostAddress());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return addr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void handleServiceInfo(ServiceInfo info) throws UnsupportedEncodingException {
|
||||
Inet4Address v4Addrs[] = info.getInet4Addresses();
|
||||
Inet6Address v6Addrs[] = info.getInet6Addresses();
|
||||
|
||||
LimeLog.info("mDNS: "+info.getName()+" has "+v4Addrs.length+" IPv4 addresses");
|
||||
LimeLog.info("mDNS: "+info.getName()+" has "+v6Addrs.length+" IPv6 addresses");
|
||||
|
||||
Inet6Address v6GlobalAddr = getBestIpv6Address(v6Addrs);
|
||||
|
||||
// Add a computer object for each IPv4 address reported by the PC
|
||||
for (Inet4Address v4Addr : v4Addrs) {
|
||||
synchronized (computers) {
|
||||
MdnsComputer computer = new MdnsComputer(info.getName(), v4Addr, v6GlobalAddr);
|
||||
if (computers.put(computer.getLocalAddress(), computer) == null) {
|
||||
// This was a new entry
|
||||
listener.notifyComputerAdded(computer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there were no IPv4 addresses, use IPv6 for registration
|
||||
if (v4Addrs.length == 0) {
|
||||
Inet6Address v6LocalAddr = getLocalAddress(v6Addrs);
|
||||
|
||||
if (v6LocalAddr != null || v6GlobalAddr != null) {
|
||||
MdnsComputer computer = new MdnsComputer(info.getName(), v6LocalAddr, v6GlobalAddr);
|
||||
if (computers.put(v6LocalAddr != null ?
|
||||
computer.getLocalAddress() : computer.getIpv6Address(), computer) == null) {
|
||||
// This was a new entry
|
||||
listener.notifyComputerAdded(computer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void startDiscovery(final int discoveryIntervalMs) {
|
||||
// Kill any existing discovery before starting a new one
|
||||
stopDiscovery();
|
||||
|
||||
// Add our listener to the set
|
||||
synchronized (listeners) {
|
||||
listeners.add(MdnsDiscoveryAgent.this);
|
||||
}
|
||||
|
||||
discoveryThread = new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
// This may result in listener callbacks so we must register
|
||||
// our listener first.
|
||||
JmmDNS resolver = referenceResolver();
|
||||
|
||||
try {
|
||||
while (!Thread.interrupted()) {
|
||||
// Start an mDNS request
|
||||
resolver.requestServiceInfo(SERVICE_TYPE, null, discoveryIntervalMs);
|
||||
|
||||
// Run service resolution again for pending machines
|
||||
ArrayList<String> pendingNames;
|
||||
synchronized (pendingResolution) {
|
||||
pendingNames = new ArrayList<String>(pendingResolution);
|
||||
}
|
||||
for (String name : pendingNames) {
|
||||
LimeLog.info("mDNS: Retrying service resolution for machine: "+name);
|
||||
ServiceInfo[] infos = resolver.getServiceInfos(SERVICE_TYPE, name, 500);
|
||||
if (infos != null && infos.length != 0) {
|
||||
LimeLog.info("mDNS: Resolved (retry) with "+infos.length+" service entries");
|
||||
for (ServiceInfo svcinfo : infos) {
|
||||
handleResolvedServiceInfo(svcinfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the next polling interval
|
||||
try {
|
||||
Thread.sleep(discoveryIntervalMs);
|
||||
} catch (InterruptedException e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
// Dereference the resolver
|
||||
dereferenceResolver();
|
||||
}
|
||||
}
|
||||
};
|
||||
discoveryThread.setName("mDNS Discovery Thread");
|
||||
discoveryThread.start();
|
||||
}
|
||||
|
||||
public void stopDiscovery() {
|
||||
// Remove our listener from the set
|
||||
synchronized (listeners) {
|
||||
listeners.remove(MdnsDiscoveryAgent.this);
|
||||
}
|
||||
|
||||
// If there's already a running thread, interrupt it
|
||||
if (discoveryThread != null) {
|
||||
discoveryThread.interrupt();
|
||||
discoveryThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
public List<MdnsComputer> getComputerSet() {
|
||||
synchronized (computers) {
|
||||
return new ArrayList<MdnsComputer>(computers.values());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serviceAdded(ServiceEvent event) {
|
||||
LimeLog.info("mDNS: Machine appeared: "+event.getInfo().getName());
|
||||
|
||||
ServiceInfo info = event.getDNS().getServiceInfo(SERVICE_TYPE, event.getInfo().getName(), 500);
|
||||
if (info == null) {
|
||||
// This machine is pending resolution
|
||||
synchronized (pendingResolution) {
|
||||
pendingResolution.add(event.getInfo().getName());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
LimeLog.info("mDNS: Resolved (blocking)");
|
||||
handleResolvedServiceInfo(info);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serviceRemoved(ServiceEvent event) {
|
||||
LimeLog.info("mDNS: Machine disappeared: "+event.getInfo().getName());
|
||||
|
||||
Inet4Address v4Addrs[] = event.getInfo().getInet4Addresses();
|
||||
for (Inet4Address addr : v4Addrs) {
|
||||
synchronized (computers) {
|
||||
MdnsComputer computer = computers.remove(addr);
|
||||
if (computer != null) {
|
||||
listener.notifyComputerRemoved(computer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Inet6Address v6Addrs[] = event.getInfo().getInet6Addresses();
|
||||
for (Inet6Address addr : v6Addrs) {
|
||||
synchronized (computers) {
|
||||
MdnsComputer computer = computers.remove(addr);
|
||||
if (computer != null) {
|
||||
listener.notifyComputerRemoved(computer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serviceResolved(ServiceEvent event) {
|
||||
// We handle this synchronously
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.limelight.nvstream.mdns;
|
||||
|
||||
public interface MdnsDiscoveryListener {
|
||||
void notifyComputerAdded(MdnsComputer computer);
|
||||
void notifyComputerRemoved(MdnsComputer computer);
|
||||
void notifyDiscoveryFailure(Exception e);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.limelight.nvstream.wol;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.util.Scanner;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
|
||||
public class WakeOnLanSender {
|
||||
private static final int[] PORTS_TO_TRY = new int[] {
|
||||
9, // Standard WOL port (privileged port)
|
||||
47998, 47999, 48000, 48002, 48010, // Ports opened by GFE
|
||||
47009, // Port opened by Moonlight Internet Hosting Tool for WoL (non-privileged port)
|
||||
};
|
||||
|
||||
public static void sendWolPacket(ComputerDetails computer) throws IOException {
|
||||
DatagramSocket sock = new DatagramSocket(0);
|
||||
byte[] payload = createWolPayload(computer);
|
||||
IOException lastException = null;
|
||||
boolean sentWolPacket = false;
|
||||
|
||||
try {
|
||||
// Try all resolved remote and local addresses and IPv4 broadcast address.
|
||||
// The broadcast address is required to avoid stale ARP cache entries
|
||||
// making the sleeping machine unreachable.
|
||||
for (String unresolvedAddress : new String[] {
|
||||
computer.localAddress, computer.remoteAddress, computer.manualAddress, computer.ipv6Address, "255.255.255.255"
|
||||
}) {
|
||||
if (unresolvedAddress == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
for (InetAddress resolvedAddress : InetAddress.getAllByName(unresolvedAddress)) {
|
||||
// Try all the ports for each resolved address
|
||||
for (int port : PORTS_TO_TRY) {
|
||||
DatagramPacket dp = new DatagramPacket(payload, payload.length);
|
||||
dp.setAddress(resolvedAddress);
|
||||
dp.setPort(port);
|
||||
sock.send(dp);
|
||||
sentWolPacket = true;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// We may have addresses that don't resolve on this subnet,
|
||||
// but don't throw and exit the whole function if that happens.
|
||||
// We'll throw it at the end if we didn't send a single packet.
|
||||
e.printStackTrace();
|
||||
lastException = e;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
sock.close();
|
||||
}
|
||||
|
||||
// Propagate the DNS resolution exception if we didn't
|
||||
// manage to get a single packet out to the host.
|
||||
if (!sentWolPacket && lastException != null) {
|
||||
throw lastException;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] macStringToBytes(String macAddress) {
|
||||
byte[] macBytes = new byte[6];
|
||||
@SuppressWarnings("resource")
|
||||
Scanner scan = new Scanner(macAddress).useDelimiter(":");
|
||||
for (int i = 0; i < macBytes.length && scan.hasNext(); i++) {
|
||||
try {
|
||||
macBytes[i] = (byte) Integer.parseInt(scan.next(), 16);
|
||||
} catch (NumberFormatException e) {
|
||||
LimeLog.warning("Malformed MAC address: "+macAddress+" (index: "+i+")");
|
||||
break;
|
||||
}
|
||||
}
|
||||
scan.close();
|
||||
return macBytes;
|
||||
}
|
||||
|
||||
private static byte[] createWolPayload(ComputerDetails computer) {
|
||||
byte[] payload = new byte[102];
|
||||
byte[] macAddress = macStringToBytes(computer.macAddress);
|
||||
int i;
|
||||
|
||||
// 6 bytes of FF
|
||||
for (i = 0; i < 6; i++) {
|
||||
payload[i] = (byte)0xFF;
|
||||
}
|
||||
|
||||
// 16 repetitions of the MAC address
|
||||
for (int j = 0; j < 16; j++) {
|
||||
System.arraycopy(macAddress, 0, payload, i, macAddress.length);
|
||||
i += macAddress.length;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
package com.limelight.preferences;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InterfaceAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.net.SocketException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
import com.limelight.binding.PlatformBinding;
|
||||
import com.limelight.computers.ComputerManagerService;
|
||||
import com.limelight.R;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.NvHTTP;
|
||||
import com.limelight.nvstream.jni.MoonBridge;
|
||||
import com.limelight.utils.Dialog;
|
||||
import com.limelight.utils.ServerHelper;
|
||||
import com.limelight.utils.SpinnerDialog;
|
||||
import com.limelight.utils.UiHelper;
|
||||
|
||||
@@ -17,6 +30,7 @@ import android.content.ServiceConnection;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.TextView;
|
||||
@@ -39,36 +53,108 @@ public class AddComputerManually extends Activity {
|
||||
}
|
||||
};
|
||||
|
||||
private boolean isWrongSubnetSiteLocalAddress(String address) {
|
||||
try {
|
||||
InetAddress targetAddress = InetAddress.getByName(address);
|
||||
if (!(targetAddress instanceof Inet4Address) || !targetAddress.isSiteLocalAddress()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We have a site-local address. Look for a matching local interface.
|
||||
for (NetworkInterface iface : Collections.list(NetworkInterface.getNetworkInterfaces())) {
|
||||
for (InterfaceAddress addr : iface.getInterfaceAddresses()) {
|
||||
if (!(addr.getAddress() instanceof Inet4Address) || !addr.getAddress().isSiteLocalAddress()) {
|
||||
// Skip non-site-local or non-IPv4 addresses
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] targetAddrBytes = targetAddress.getAddress();
|
||||
byte[] ifaceAddrBytes = addr.getAddress().getAddress();
|
||||
|
||||
// Compare prefix to ensure it's the same
|
||||
boolean addressMatches = true;
|
||||
for (int i = 0; i < addr.getNetworkPrefixLength(); i++) {
|
||||
if ((ifaceAddrBytes[i / 8] & (1 << (i % 8))) != (targetAddrBytes[i / 8] & (1 << (i % 8)))) {
|
||||
addressMatches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (addressMatches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Couldn't find a matching interface
|
||||
return true;
|
||||
} catch (SocketException e) {
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
} catch (UnknownHostException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void doAddPc(String host) {
|
||||
String msg;
|
||||
boolean finish = false;
|
||||
boolean wrongSiteLocal = false;
|
||||
boolean success;
|
||||
int portTestResult;
|
||||
|
||||
SpinnerDialog dialog = SpinnerDialog.displayDialog(this, getResources().getString(R.string.title_add_pc),
|
||||
getResources().getString(R.string.msg_add_pc), false);
|
||||
|
||||
if (!managerBinder.addComputerBlocking(host, true)){
|
||||
msg = getResources().getString(R.string.addpc_fail);
|
||||
try {
|
||||
ComputerDetails details = new ComputerDetails();
|
||||
details.manualAddress = host;
|
||||
success = managerBinder.addComputerBlocking(details);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// This can be thrown from OkHttp if the host fails to canonicalize to a valid name.
|
||||
// https://github.com/square/okhttp/blob/okhttp_27/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java#L705
|
||||
e.printStackTrace();
|
||||
success = false;
|
||||
}
|
||||
else {
|
||||
msg = getResources().getString(R.string.addpc_success);
|
||||
finish = true;
|
||||
if (!success){
|
||||
wrongSiteLocal = isWrongSubnetSiteLocalAddress(host);
|
||||
}
|
||||
if (!success && !wrongSiteLocal) {
|
||||
// Run the test before dismissing the spinner because it can take a few seconds.
|
||||
portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443,
|
||||
MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989);
|
||||
} else {
|
||||
// Don't bother with the test if we succeeded or the IP address was bogus
|
||||
portTestResult = MoonBridge.ML_TEST_RESULT_INCONCLUSIVE;
|
||||
}
|
||||
|
||||
dialog.dismiss();
|
||||
|
||||
final boolean toastFinish = finish;
|
||||
final String toastMsg = msg;
|
||||
AddComputerManually.this.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Toast.makeText(AddComputerManually.this, toastMsg, Toast.LENGTH_LONG).show();
|
||||
if (wrongSiteLocal) {
|
||||
Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_wrong_sitelocal), false);
|
||||
}
|
||||
else if (!success) {
|
||||
String dialogText;
|
||||
if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) {
|
||||
dialogText = getResources().getString(R.string.nettest_text_blocked);
|
||||
}
|
||||
else {
|
||||
dialogText = getResources().getString(R.string.addpc_fail);
|
||||
}
|
||||
Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), dialogText, false);
|
||||
}
|
||||
else {
|
||||
AddComputerManually.this.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_success), Toast.LENGTH_LONG).show();
|
||||
|
||||
if (toastFinish && !isFinishing()) {
|
||||
if (!isFinishing()) {
|
||||
// Close the activity
|
||||
AddComputerManually.this.finish();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void startAddThread() {
|
||||
@@ -141,12 +227,7 @@ public class AddComputerManually extends Activity {
|
||||
(keyEvent != null &&
|
||||
keyEvent.getAction() == KeyEvent.ACTION_DOWN &&
|
||||
keyEvent.getKeyCode() == KeyEvent.KEYCODE_ENTER)) {
|
||||
if (hostText.getText().length() == 0) {
|
||||
Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_enter_ip), Toast.LENGTH_LONG).show();
|
||||
return true;
|
||||
}
|
||||
|
||||
computersToAdd.add(hostText.getText().toString().trim());
|
||||
return handleDoneEvent();
|
||||
}
|
||||
else if (actionId == EditorInfo.IME_ACTION_PREVIOUS) {
|
||||
// This is how the Fire TV dismisses the keyboard
|
||||
@@ -159,8 +240,28 @@ public class AddComputerManually extends Activity {
|
||||
}
|
||||
});
|
||||
|
||||
findViewById(R.id.addPcButton).setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
handleDoneEvent();
|
||||
}
|
||||
});
|
||||
|
||||
// Bind to the ComputerManager service
|
||||
bindService(new Intent(AddComputerManually.this,
|
||||
ComputerManagerService.class), serviceConnection, Service.BIND_AUTO_CREATE);
|
||||
}
|
||||
|
||||
// Returns true if the event should be eaten
|
||||
private boolean handleDoneEvent() {
|
||||
String hostAddress = hostText.getText().toString().trim();
|
||||
|
||||
if (hostAddress.length() == 0) {
|
||||
Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_enter_ip), Toast.LENGTH_LONG).show();
|
||||
return true;
|
||||
}
|
||||
|
||||
computersToAdd.add(hostAddress);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.limelight.preferences;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Build;
|
||||
import android.preference.DialogPreference;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.limelight.R;
|
||||
|
||||
import static com.limelight.binding.input.virtual_controller.VirtualControllerConfigurationLoader.OSC_PREFERENCE;
|
||||
|
||||
public class ConfirmDeleteOscPreference extends DialogPreference {
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
}
|
||||
|
||||
public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public ConfirmDeleteOscPreference(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public ConfirmDeleteOscPreference(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
if (which == DialogInterface.BUTTON_POSITIVE) {
|
||||
getContext().getSharedPreferences(OSC_PREFERENCE, Context.MODE_PRIVATE).edit().clear().apply();
|
||||
Toast.makeText(getContext(), R.string.toast_reset_osc_success, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,95 +6,190 @@ import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
import com.limelight.nvstream.jni.MoonBridge;
|
||||
|
||||
public class PreferenceConfiguration {
|
||||
static final String RES_FPS_PREF_STRING = "list_resolution_fps";
|
||||
static final String BITRATE_PREF_STRING = "seekbar_bitrate";
|
||||
private static final String LEGACY_RES_FPS_PREF_STRING = "list_resolution_fps";
|
||||
private static final String LEGACY_ENABLE_51_SURROUND_PREF_STRING = "checkbox_51_surround";
|
||||
|
||||
static final String RESOLUTION_PREF_STRING = "list_resolution";
|
||||
static final String FPS_PREF_STRING = "list_fps";
|
||||
static final String BITRATE_PREF_STRING = "seekbar_bitrate_kbps";
|
||||
private static final String BITRATE_PREF_OLD_STRING = "seekbar_bitrate";
|
||||
private static final String STRETCH_PREF_STRING = "checkbox_stretch_video";
|
||||
private static final String SOPS_PREF_STRING = "checkbox_enable_sops";
|
||||
private static final String DISABLE_TOASTS_PREF_STRING = "checkbox_disable_warnings";
|
||||
private static final String HOST_AUDIO_PREF_STRING = "checkbox_host_audio";
|
||||
private static final String DEADZONE_PREF_STRING = "seekbar_deadzone";
|
||||
private static final String OSC_OPACITY_PREF_STRING = "seekbar_osc_opacity";
|
||||
private static final String LANGUAGE_PREF_STRING = "list_languages";
|
||||
private static final String LIST_MODE_PREF_STRING = "checkbox_list_mode";
|
||||
private static final String SMALL_ICONS_PREF_STRING = "checkbox_small_icon_mode";
|
||||
private static final String MULTI_CONTROLLER_PREF_STRING = "checkbox_multi_controller";
|
||||
private static final String ENABLE_51_SURROUND_PREF_STRING = "checkbox_51_surround";
|
||||
static final String AUDIO_CONFIG_PREF_STRING = "list_audio_config";
|
||||
private static final String USB_DRIVER_PREF_SRING = "checkbox_usb_driver";
|
||||
private static final String VIDEO_FORMAT_PREF_STRING = "video_format";
|
||||
private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls";
|
||||
private static final String ONLY_L3_R3_PREF_STRING = "checkbox_only_show_L3R3";
|
||||
private static final String BATTERY_SAVER_PREF_STRING = "checkbox_battery_saver";
|
||||
private static final String DISABLE_FRAME_DROP_PREF_STRING = "checkbox_disable_frame_drop";
|
||||
private static final String ENABLE_HDR_PREF_STRING = "checkbox_enable_hdr";
|
||||
private static final String ENABLE_PIP_PREF_STRING = "checkbox_enable_pip";
|
||||
private static final String ENABLE_PERF_OVERLAY_STRING = "checkbox_enable_perf_overlay";
|
||||
private static final String BIND_ALL_USB_STRING = "checkbox_usb_bind_all";
|
||||
private static final String MOUSE_EMULATION_STRING = "checkbox_mouse_emulation";
|
||||
private static final String MOUSE_NAV_BUTTONS_STRING = "checkbox_mouse_nav_buttons";
|
||||
static final String UNLOCK_FPS_STRING = "checkbox_unlock_fps";
|
||||
private static final String VIBRATE_OSC_PREF_STRING = "checkbox_vibrate_osc";
|
||||
private static final String VIBRATE_FALLBACK_PREF_STRING = "checkbox_vibrate_fallback";
|
||||
private static final String FLIP_FACE_BUTTONS_PREF_STRING = "checkbox_flip_face_buttons";
|
||||
private static final String TOUCHSCREEN_TRACKPAD_PREF_STRING = "checkbox_touchscreen_trackpad";
|
||||
private static final String LATENCY_TOAST_PREF_STRING = "checkbox_enable_post_stream_toast";
|
||||
|
||||
private static final int BITRATE_DEFAULT_720_30 = 5;
|
||||
private static final int BITRATE_DEFAULT_720_60 = 10;
|
||||
private static final int BITRATE_DEFAULT_1080_30 = 10;
|
||||
private static final int BITRATE_DEFAULT_1080_60 = 20;
|
||||
private static final int BITRATE_DEFAULT_4K_30 = 40;
|
||||
private static final int BITRATE_DEFAULT_4K_60 = 80;
|
||||
|
||||
private static final String DEFAULT_RES_FPS = "720p60";
|
||||
private static final int DEFAULT_BITRATE = BITRATE_DEFAULT_720_60;
|
||||
static final String DEFAULT_RESOLUTION = "1280x720";
|
||||
static final String DEFAULT_FPS = "60";
|
||||
private static final boolean DEFAULT_STRETCH = false;
|
||||
private static final boolean DEFAULT_SOPS = true;
|
||||
private static final boolean DEFAULT_DISABLE_TOASTS = false;
|
||||
private static final boolean DEFAULT_HOST_AUDIO = false;
|
||||
private static final int DEFAULT_DEADZONE = 15;
|
||||
private static final int DEFAULT_OPACITY = 90;
|
||||
public static final String DEFAULT_LANGUAGE = "default";
|
||||
private static final boolean DEFAULT_LIST_MODE = false;
|
||||
private static final boolean DEFAULT_MULTI_CONTROLLER = true;
|
||||
private static final boolean DEFAULT_ENABLE_51_SURROUND = false;
|
||||
private static final boolean DEFAULT_USB_DRIVER = true;
|
||||
private static final String DEFAULT_VIDEO_FORMAT = "auto";
|
||||
private static final boolean ONSCREEN_CONTROLLER_DEFAULT = false;
|
||||
private static final boolean ONLY_L3_R3_DEFAULT = false;
|
||||
private static final boolean DEFAULT_BATTERY_SAVER = false;
|
||||
private static final boolean DEFAULT_DISABLE_FRAME_DROP = false;
|
||||
private static final boolean DEFAULT_ENABLE_HDR = false;
|
||||
private static final boolean DEFAULT_ENABLE_PIP = false;
|
||||
private static final boolean DEFAULT_ENABLE_PERF_OVERLAY = false;
|
||||
private static final boolean DEFAULT_BIND_ALL_USB = false;
|
||||
private static final boolean DEFAULT_MOUSE_EMULATION = true;
|
||||
private static final boolean DEFAULT_MOUSE_NAV_BUTTONS = false;
|
||||
private static final boolean DEFAULT_UNLOCK_FPS = false;
|
||||
private static final boolean DEFAULT_VIBRATE_OSC = true;
|
||||
private static final boolean DEFAULT_VIBRATE_FALLBACK = false;
|
||||
private static final boolean DEFAULT_FLIP_FACE_BUTTONS = false;
|
||||
private static final boolean DEFAULT_TOUCHSCREEN_TRACKPAD = true;
|
||||
private static final String DEFAULT_AUDIO_CONFIG = "2"; // Stereo
|
||||
private static final boolean DEFAULT_LATENCY_TOAST = false;
|
||||
|
||||
public static final int FORCE_H265_ON = -1;
|
||||
public static final int AUTOSELECT_H265 = 0;
|
||||
public static final int FORCE_H265_OFF = 1;
|
||||
|
||||
public static final String RES_360P = "640x360";
|
||||
public static final String RES_480P = "854x480";
|
||||
public static final String RES_720P = "1280x720";
|
||||
public static final String RES_1080P = "1920x1080";
|
||||
public static final String RES_1440P = "2560x1440";
|
||||
public static final String RES_4K = "3840x2160";
|
||||
|
||||
public int width, height, fps;
|
||||
public int bitrate;
|
||||
public int videoFormat;
|
||||
public int deadzonePercentage;
|
||||
public int oscOpacity;
|
||||
public boolean stretchVideo, enableSops, playHostAudio, disableWarnings;
|
||||
public String language;
|
||||
public boolean listMode, smallIconMode, multiController, enable51Surround, usbDriver;
|
||||
public boolean smallIconMode, multiController, usbDriver, flipFaceButtons;
|
||||
public boolean onscreenController;
|
||||
public boolean onlyL3R3;
|
||||
public boolean batterySaver;
|
||||
public boolean disableFrameDrop;
|
||||
public boolean enableHdr;
|
||||
public boolean enablePip;
|
||||
public boolean enablePerfOverlay;
|
||||
public boolean enableLatencyToast;
|
||||
public boolean bindAllUsb;
|
||||
public boolean mouseEmulation;
|
||||
public boolean mouseNavButtons;
|
||||
public boolean unlockFps;
|
||||
public boolean vibrateOsc;
|
||||
public boolean vibrateFallbackToDevice;
|
||||
public boolean touchscreenTrackpad;
|
||||
public MoonBridge.AudioConfiguration audioConfiguration;
|
||||
|
||||
public static int getDefaultBitrate(String resFpsString) {
|
||||
if (resFpsString.equals("720p30")) {
|
||||
return BITRATE_DEFAULT_720_30;
|
||||
private static String convertFromLegacyResolutionString(String resString) {
|
||||
if (resString.equalsIgnoreCase("360p")) {
|
||||
return RES_360P;
|
||||
}
|
||||
else if (resFpsString.equals("720p60")) {
|
||||
return BITRATE_DEFAULT_720_60;
|
||||
else if (resString.equalsIgnoreCase("480p")) {
|
||||
return RES_480P;
|
||||
}
|
||||
else if (resFpsString.equals("1080p30")) {
|
||||
return BITRATE_DEFAULT_1080_30;
|
||||
else if (resString.equalsIgnoreCase("720p")) {
|
||||
return RES_720P;
|
||||
}
|
||||
else if (resFpsString.equals("1080p60")) {
|
||||
return BITRATE_DEFAULT_1080_60;
|
||||
else if (resString.equalsIgnoreCase("1080p")) {
|
||||
return RES_1080P;
|
||||
}
|
||||
else if (resFpsString.equals("4K30")) {
|
||||
return BITRATE_DEFAULT_4K_30;
|
||||
else if (resString.equalsIgnoreCase("1440p")) {
|
||||
return RES_1440P;
|
||||
}
|
||||
else if (resFpsString.equals("4K60")) {
|
||||
return BITRATE_DEFAULT_4K_60;
|
||||
else if (resString.equalsIgnoreCase("4K")) {
|
||||
return RES_4K;
|
||||
}
|
||||
else {
|
||||
// Should never get here
|
||||
return DEFAULT_BITRATE;
|
||||
// Should be unreachable
|
||||
return RES_720P;
|
||||
}
|
||||
}
|
||||
|
||||
private static int getWidthFromResolutionString(String resString) {
|
||||
return Integer.parseInt(resString.split("x")[0]);
|
||||
}
|
||||
|
||||
private static int getHeightFromResolutionString(String resString) {
|
||||
return Integer.parseInt(resString.split("x")[1]);
|
||||
}
|
||||
|
||||
private static String getResolutionString(int width, int height) {
|
||||
switch (height) {
|
||||
case 360:
|
||||
return RES_360P;
|
||||
case 480:
|
||||
return RES_480P;
|
||||
default:
|
||||
case 720:
|
||||
return RES_720P;
|
||||
case 1080:
|
||||
return RES_1080P;
|
||||
case 1440:
|
||||
return RES_1440P;
|
||||
case 2160:
|
||||
return RES_4K;
|
||||
}
|
||||
}
|
||||
|
||||
public static int getDefaultBitrate(String resString, String fpsString) {
|
||||
int width = getWidthFromResolutionString(resString);
|
||||
int height = getHeightFromResolutionString(resString);
|
||||
int fps = Integer.parseInt(fpsString);
|
||||
|
||||
// This table prefers 16:10 resolutions because they are
|
||||
// only slightly more pixels than the 16:9 equivalents, so
|
||||
// we don't want to bump those 16:10 resolutions up to the
|
||||
// next 16:9 slot.
|
||||
//
|
||||
// This logic is shamelessly stolen from Moonlight Qt:
|
||||
// https://github.com/moonlight-stream/moonlight-qt/blob/master/app/settings/streamingpreferences.cpp
|
||||
|
||||
if (width * height <= 640 * 360) {
|
||||
return (int)(1000 * (fps / 30.0));
|
||||
}
|
||||
else if (width * height <= 854 * 480) {
|
||||
return (int)(1500 * (fps / 30.0));
|
||||
}
|
||||
// This covers 1280x720 and 1280x800 too
|
||||
else if (width * height <= 1366 * 768) {
|
||||
return (int)(5000 * (fps / 30.0));
|
||||
}
|
||||
else if (width * height <= 1920 * 1200) {
|
||||
return (int)(10000 * (fps / 30.0));
|
||||
}
|
||||
else if (width * height <= 2560 * 1600) {
|
||||
return (int)(20000 * (fps / 30.0));
|
||||
}
|
||||
else /* if (width * height <= 3840 * 2160) */ {
|
||||
return (int)(40000 * (fps / 30.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +215,9 @@ public class PreferenceConfiguration {
|
||||
|
||||
public static int getDefaultBitrate(Context context) {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
return getDefaultBitrate(prefs.getString(RES_FPS_PREF_STRING, DEFAULT_RES_FPS));
|
||||
return getDefaultBitrate(
|
||||
prefs.getString(RESOLUTION_PREF_STRING, DEFAULT_RESOLUTION),
|
||||
prefs.getString(FPS_PREF_STRING, DEFAULT_FPS));
|
||||
}
|
||||
|
||||
private static int getVideoFormatValue(Context context) {
|
||||
@@ -147,9 +244,13 @@ public class PreferenceConfiguration {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
prefs.edit()
|
||||
.remove(BITRATE_PREF_STRING)
|
||||
.remove(RES_FPS_PREF_STRING)
|
||||
.remove(BITRATE_PREF_OLD_STRING)
|
||||
.remove(LEGACY_RES_FPS_PREF_STRING)
|
||||
.remove(RESOLUTION_PREF_STRING)
|
||||
.remove(FPS_PREF_STRING)
|
||||
.remove(VIDEO_FORMAT_PREF_STRING)
|
||||
.remove(ENABLE_HDR_PREF_STRING)
|
||||
.remove(UNLOCK_FPS_STRING)
|
||||
.apply();
|
||||
}
|
||||
|
||||
@@ -157,49 +258,115 @@ public class PreferenceConfiguration {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
PreferenceConfiguration config = new PreferenceConfiguration();
|
||||
|
||||
config.bitrate = prefs.getInt(BITRATE_PREF_STRING, getDefaultBitrate(context));
|
||||
String str = prefs.getString(RES_FPS_PREF_STRING, DEFAULT_RES_FPS);
|
||||
if (str.equals("720p30")) {
|
||||
config.width = 1280;
|
||||
config.height = 720;
|
||||
config.fps = 30;
|
||||
// Migrate legacy preferences to the new locations
|
||||
if (prefs.contains(LEGACY_ENABLE_51_SURROUND_PREF_STRING)) {
|
||||
if (prefs.getBoolean(LEGACY_ENABLE_51_SURROUND_PREF_STRING, false)) {
|
||||
prefs.edit()
|
||||
.remove(LEGACY_ENABLE_51_SURROUND_PREF_STRING)
|
||||
.putString(AUDIO_CONFIG_PREF_STRING, "51")
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
else if (str.equals("720p60")) {
|
||||
config.width = 1280;
|
||||
config.height = 720;
|
||||
config.fps = 60;
|
||||
}
|
||||
else if (str.equals("1080p30")) {
|
||||
config.width = 1920;
|
||||
config.height = 1080;
|
||||
config.fps = 30;
|
||||
}
|
||||
else if (str.equals("1080p60")) {
|
||||
config.width = 1920;
|
||||
config.height = 1080;
|
||||
config.fps = 60;
|
||||
}
|
||||
else if (str.equals("4K30")) {
|
||||
config.width = 3840;
|
||||
config.height = 2160;
|
||||
config.fps = 30;
|
||||
}
|
||||
else if (str.equals("4K60")) {
|
||||
config.width = 3840;
|
||||
config.height = 2160;
|
||||
config.fps = 60;
|
||||
|
||||
String str = prefs.getString(LEGACY_RES_FPS_PREF_STRING, null);
|
||||
if (str != null) {
|
||||
if (str.equals("360p30")) {
|
||||
config.width = 640;
|
||||
config.height = 360;
|
||||
config.fps = 30;
|
||||
}
|
||||
else if (str.equals("360p60")) {
|
||||
config.width = 640;
|
||||
config.height = 360;
|
||||
config.fps = 60;
|
||||
}
|
||||
else if (str.equals("720p30")) {
|
||||
config.width = 1280;
|
||||
config.height = 720;
|
||||
config.fps = 30;
|
||||
}
|
||||
else if (str.equals("720p60")) {
|
||||
config.width = 1280;
|
||||
config.height = 720;
|
||||
config.fps = 60;
|
||||
}
|
||||
else if (str.equals("1080p30")) {
|
||||
config.width = 1920;
|
||||
config.height = 1080;
|
||||
config.fps = 30;
|
||||
}
|
||||
else if (str.equals("1080p60")) {
|
||||
config.width = 1920;
|
||||
config.height = 1080;
|
||||
config.fps = 60;
|
||||
}
|
||||
else if (str.equals("4K30")) {
|
||||
config.width = 3840;
|
||||
config.height = 2160;
|
||||
config.fps = 30;
|
||||
}
|
||||
else if (str.equals("4K60")) {
|
||||
config.width = 3840;
|
||||
config.height = 2160;
|
||||
config.fps = 60;
|
||||
}
|
||||
else {
|
||||
// Should never get here
|
||||
config.width = 1280;
|
||||
config.height = 720;
|
||||
config.fps = 60;
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
.remove(LEGACY_RES_FPS_PREF_STRING)
|
||||
.putString(RESOLUTION_PREF_STRING, getResolutionString(config.width, config.height))
|
||||
.putString(FPS_PREF_STRING, ""+config.fps)
|
||||
.apply();
|
||||
}
|
||||
else {
|
||||
// Should never get here
|
||||
config.width = 1280;
|
||||
config.height = 720;
|
||||
config.fps = 60;
|
||||
// Use the new preference location
|
||||
String resStr = prefs.getString(RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION);
|
||||
|
||||
// Convert legacy resolution strings to the new style
|
||||
if (!resStr.contains("x")) {
|
||||
resStr = PreferenceConfiguration.convertFromLegacyResolutionString(resStr);
|
||||
prefs.edit().putString(RESOLUTION_PREF_STRING, resStr).apply();
|
||||
}
|
||||
|
||||
config.width = PreferenceConfiguration.getWidthFromResolutionString(resStr);
|
||||
config.height = PreferenceConfiguration.getHeightFromResolutionString(resStr);
|
||||
config.fps = Integer.parseInt(prefs.getString(FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS));
|
||||
}
|
||||
|
||||
if (!prefs.contains(SMALL_ICONS_PREF_STRING)) {
|
||||
// We need to write small icon mode's default to disk for the settings page to display
|
||||
// the current state of the option properly
|
||||
prefs.edit().putBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context)).apply();
|
||||
}
|
||||
|
||||
// This must happen after the preferences migration to ensure the preferences are populated
|
||||
config.bitrate = prefs.getInt(BITRATE_PREF_STRING, prefs.getInt(BITRATE_PREF_OLD_STRING, 0) * 1000);
|
||||
if (config.bitrate == 0) {
|
||||
config.bitrate = getDefaultBitrate(context);
|
||||
}
|
||||
|
||||
String audioConfig = prefs.getString(AUDIO_CONFIG_PREF_STRING, DEFAULT_AUDIO_CONFIG);
|
||||
if (audioConfig.equals("71")) {
|
||||
config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_71_SURROUND;
|
||||
}
|
||||
else if (audioConfig.equals("51")) {
|
||||
config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_51_SURROUND;
|
||||
}
|
||||
else /* if (audioConfig.equals("2")) */ {
|
||||
config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO;
|
||||
}
|
||||
|
||||
config.videoFormat = getVideoFormatValue(context);
|
||||
|
||||
config.deadzonePercentage = prefs.getInt(DEADZONE_PREF_STRING, DEFAULT_DEADZONE);
|
||||
|
||||
config.oscOpacity = prefs.getInt(OSC_OPACITY_PREF_STRING, DEFAULT_OPACITY);
|
||||
|
||||
config.language = prefs.getString(LANGUAGE_PREF_STRING, DEFAULT_LANGUAGE);
|
||||
|
||||
// Checkbox preferences
|
||||
@@ -207,17 +374,24 @@ public class PreferenceConfiguration {
|
||||
config.enableSops = prefs.getBoolean(SOPS_PREF_STRING, DEFAULT_SOPS);
|
||||
config.stretchVideo = prefs.getBoolean(STRETCH_PREF_STRING, DEFAULT_STRETCH);
|
||||
config.playHostAudio = prefs.getBoolean(HOST_AUDIO_PREF_STRING, DEFAULT_HOST_AUDIO);
|
||||
config.listMode = prefs.getBoolean(LIST_MODE_PREF_STRING, DEFAULT_LIST_MODE);
|
||||
config.smallIconMode = prefs.getBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context));
|
||||
config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER);
|
||||
config.enable51Surround = prefs.getBoolean(ENABLE_51_SURROUND_PREF_STRING, DEFAULT_ENABLE_51_SURROUND);
|
||||
config.usbDriver = prefs.getBoolean(USB_DRIVER_PREF_SRING, DEFAULT_USB_DRIVER);
|
||||
config.onscreenController = prefs.getBoolean(ONSCREEN_CONTROLLER_PREF_STRING, ONSCREEN_CONTROLLER_DEFAULT);
|
||||
config.onlyL3R3 = prefs.getBoolean(ONLY_L3_R3_PREF_STRING, ONLY_L3_R3_DEFAULT);
|
||||
config.batterySaver = prefs.getBoolean(BATTERY_SAVER_PREF_STRING, DEFAULT_BATTERY_SAVER);
|
||||
config.disableFrameDrop = prefs.getBoolean(DISABLE_FRAME_DROP_PREF_STRING, DEFAULT_DISABLE_FRAME_DROP);
|
||||
config.enableHdr = prefs.getBoolean(ENABLE_HDR_PREF_STRING, DEFAULT_ENABLE_HDR);
|
||||
config.enablePip = prefs.getBoolean(ENABLE_PIP_PREF_STRING, DEFAULT_ENABLE_PIP);
|
||||
config.enablePerfOverlay = prefs.getBoolean(ENABLE_PERF_OVERLAY_STRING, DEFAULT_ENABLE_PERF_OVERLAY);
|
||||
config.bindAllUsb = prefs.getBoolean(BIND_ALL_USB_STRING, DEFAULT_BIND_ALL_USB);
|
||||
config.mouseEmulation = prefs.getBoolean(MOUSE_EMULATION_STRING, DEFAULT_MOUSE_EMULATION);
|
||||
config.mouseNavButtons = prefs.getBoolean(MOUSE_NAV_BUTTONS_STRING, DEFAULT_MOUSE_NAV_BUTTONS);
|
||||
config.unlockFps = prefs.getBoolean(UNLOCK_FPS_STRING, DEFAULT_UNLOCK_FPS);
|
||||
config.vibrateOsc = prefs.getBoolean(VIBRATE_OSC_PREF_STRING, DEFAULT_VIBRATE_OSC);
|
||||
config.vibrateFallbackToDevice = prefs.getBoolean(VIBRATE_FALLBACK_PREF_STRING, DEFAULT_VIBRATE_FALLBACK);
|
||||
config.flipFaceButtons = prefs.getBoolean(FLIP_FACE_BUTTONS_PREF_STRING, DEFAULT_FLIP_FACE_BUTTONS);
|
||||
config.touchscreenTrackpad = prefs.getBoolean(TOUCHSCREEN_TRACKPAD_PREF_STRING, DEFAULT_TOUCHSCREEN_TRACKPAD);
|
||||
config.enableLatencyToast = prefs.getBoolean(LATENCY_TOAST_PREF_STRING, DEFAULT_LATENCY_TOAST);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.preference.DialogPreference;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.View.OnClickListener;
|
||||
@@ -13,10 +14,13 @@ import android.widget.LinearLayout;
|
||||
import android.widget.SeekBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
// Based on a Stack Overflow example: http://stackoverflow.com/questions/1974193/slider-on-my-preferencescreen
|
||||
public class SeekBarPreference extends DialogPreference
|
||||
{
|
||||
private static final String SCHEMA_URL = "http://schemas.android.com/apk/res/android";
|
||||
private static final String ANDROID_SCHEMA_URL = "http://schemas.android.com/apk/res/android";
|
||||
private static final String SEEKBAR_SCHEMA_URL = "http://schemas.moonlight-stream.com/apk/res/seekbar";
|
||||
|
||||
private SeekBar seekBar;
|
||||
private TextView valueText;
|
||||
@@ -27,6 +31,9 @@ public class SeekBarPreference extends DialogPreference
|
||||
private final int defaultValue;
|
||||
private final int maxValue;
|
||||
private final int minValue;
|
||||
private final int stepSize;
|
||||
private final int keyStepSize;
|
||||
private final int divisor;
|
||||
private int currentValue;
|
||||
|
||||
public SeekBarPreference(Context context, AttributeSet attrs) {
|
||||
@@ -34,27 +41,30 @@ public class SeekBarPreference extends DialogPreference
|
||||
this.context = context;
|
||||
|
||||
// Read the message from XML
|
||||
int dialogMessageId = attrs.getAttributeResourceValue(SCHEMA_URL, "dialogMessage", 0);
|
||||
int dialogMessageId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "dialogMessage", 0);
|
||||
if (dialogMessageId == 0) {
|
||||
dialogMessage = attrs.getAttributeValue(SCHEMA_URL, "dialogMessage");
|
||||
dialogMessage = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "dialogMessage");
|
||||
}
|
||||
else {
|
||||
dialogMessage = context.getString(dialogMessageId);
|
||||
}
|
||||
|
||||
// Get the suffix for the number displayed in the dialog
|
||||
int suffixId = attrs.getAttributeResourceValue(SCHEMA_URL, "text", 0);
|
||||
int suffixId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "text", 0);
|
||||
if (suffixId == 0) {
|
||||
suffix = attrs.getAttributeValue(SCHEMA_URL, "text");
|
||||
suffix = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "text");
|
||||
}
|
||||
else {
|
||||
suffix = context.getString(suffixId);
|
||||
}
|
||||
|
||||
// Get default, min, and max seekbar values
|
||||
defaultValue = attrs.getAttributeIntValue(SCHEMA_URL, "defaultValue", PreferenceConfiguration.getDefaultBitrate(context));
|
||||
maxValue = attrs.getAttributeIntValue(SCHEMA_URL, "max", 100);
|
||||
minValue = 1;
|
||||
defaultValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "defaultValue", PreferenceConfiguration.getDefaultBitrate(context));
|
||||
maxValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "max", 100);
|
||||
minValue = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "min", 1);
|
||||
stepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "step", 1);
|
||||
divisor = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "divisor", 1);
|
||||
keyStepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "keyStep", 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -75,6 +85,8 @@ public class SeekBarPreference extends DialogPreference
|
||||
valueText = new TextView(context);
|
||||
valueText.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
valueText.setTextSize(32);
|
||||
// Default text for value; hides bug where OnSeekBarChangeListener isn't called when opacity is 0%
|
||||
valueText.setText("0%");
|
||||
params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT);
|
||||
@@ -89,7 +101,20 @@ public class SeekBarPreference extends DialogPreference
|
||||
return;
|
||||
}
|
||||
|
||||
String t = String.valueOf(value);
|
||||
int roundedValue = ((value + (stepSize - 1))/stepSize)*stepSize;
|
||||
if (roundedValue != value) {
|
||||
seekBar.setProgress(roundedValue);
|
||||
return;
|
||||
}
|
||||
|
||||
String t;
|
||||
if (divisor != 1) {
|
||||
float floatValue = roundedValue / (float)divisor;
|
||||
t = String.format((Locale)null, "%.1f", floatValue);
|
||||
}
|
||||
else {
|
||||
t = String.valueOf(value);
|
||||
}
|
||||
valueText.setText(suffix == null ? t : t.concat(suffix.length() > 1 ? " "+suffix : suffix));
|
||||
}
|
||||
|
||||
@@ -107,6 +132,9 @@ public class SeekBarPreference extends DialogPreference
|
||||
}
|
||||
|
||||
seekBar.setMax(maxValue);
|
||||
if (keyStepSize != 0) {
|
||||
seekBar.setKeyProgressIncrement(keyStepSize);
|
||||
}
|
||||
seekBar.setProgress(currentValue);
|
||||
|
||||
return layout;
|
||||
@@ -116,6 +144,9 @@ public class SeekBarPreference extends DialogPreference
|
||||
protected void onBindDialogView(View v) {
|
||||
super.onBindDialogView(v);
|
||||
seekBar.setMax(maxValue);
|
||||
if (keyStepSize != 0) {
|
||||
seekBar.setKeyProgressIncrement(keyStepSize);
|
||||
}
|
||||
seekBar.setProgress(currentValue);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.limelight.preferences;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.app.Activity;
|
||||
import android.os.Handler;
|
||||
import android.os.Vibrator;
|
||||
import android.preference.ListPreference;
|
||||
import android.preference.Preference;
|
||||
import android.preference.PreferenceCategory;
|
||||
@@ -14,6 +17,9 @@ import android.preference.PreferenceManager;
|
||||
import android.preference.PreferenceScreen;
|
||||
import android.util.Range;
|
||||
import android.view.Display;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.PcView;
|
||||
@@ -24,6 +30,12 @@ import com.limelight.utils.UiHelper;
|
||||
public class StreamSettings extends Activity {
|
||||
private PreferenceConfiguration previousPrefs;
|
||||
|
||||
void reloadSettings() {
|
||||
getFragmentManager().beginTransaction().replace(
|
||||
R.id.stream_settings, new SettingsFragment()
|
||||
).commit();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -33,9 +45,7 @@ public class StreamSettings extends Activity {
|
||||
UiHelper.setLocale(this);
|
||||
|
||||
setContentView(R.layout.activity_stream_settings);
|
||||
getFragmentManager().beginTransaction().replace(
|
||||
R.id.stream_settings, new SettingsFragment()
|
||||
).commit();
|
||||
reloadSettings();
|
||||
|
||||
UiHelper.notifyNewRootView(this);
|
||||
}
|
||||
@@ -46,9 +56,7 @@ public class StreamSettings extends Activity {
|
||||
|
||||
// Check for changes that require a UI reload to take effect
|
||||
PreferenceConfiguration newPrefs = PreferenceConfiguration.readPreferences(this);
|
||||
if (newPrefs.listMode != previousPrefs.listMode ||
|
||||
newPrefs.smallIconMode != previousPrefs.smallIconMode ||
|
||||
!newPrefs.language.equals(previousPrefs.language)) {
|
||||
if (!newPrefs.language.equals(previousPrefs.language)) {
|
||||
// Restart the PC view to apply UI changes
|
||||
Intent intent = new Intent(this, PcView.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
@@ -58,12 +66,20 @@ public class StreamSettings extends Activity {
|
||||
|
||||
public static class SettingsFragment extends PreferenceFragment {
|
||||
|
||||
private static void removeResolution(ListPreference pref, String prefix) {
|
||||
private void setValue(String preferenceKey, String value) {
|
||||
ListPreference pref = (ListPreference) findPreference(preferenceKey);
|
||||
|
||||
pref.setValue(value);
|
||||
}
|
||||
|
||||
private void removeValue(String preferenceKey, String value, Runnable onMatched) {
|
||||
int matchingCount = 0;
|
||||
|
||||
ListPreference pref = (ListPreference) findPreference(preferenceKey);
|
||||
|
||||
// Count the number of matching entries we'll be removing
|
||||
for (CharSequence seq : pref.getEntryValues()) {
|
||||
if (seq.toString().startsWith(prefix)) {
|
||||
if (seq.toString().equalsIgnoreCase(value)) {
|
||||
matchingCount++;
|
||||
}
|
||||
}
|
||||
@@ -73,8 +89,8 @@ public class StreamSettings extends Activity {
|
||||
CharSequence[] entryValues = new CharSequence[pref.getEntryValues().length-matchingCount];
|
||||
int outIndex = 0;
|
||||
for (int i = 0; i < pref.getEntryValues().length; i++) {
|
||||
if (pref.getEntryValues()[i].toString().startsWith(prefix)) {
|
||||
// Skip matching prefixes
|
||||
if (pref.getEntryValues()[i].toString().equalsIgnoreCase(value)) {
|
||||
// Skip matching values
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -83,11 +99,38 @@ public class StreamSettings extends Activity {
|
||||
outIndex++;
|
||||
}
|
||||
|
||||
if (pref.getValue().equalsIgnoreCase(value)) {
|
||||
onMatched.run();
|
||||
}
|
||||
|
||||
// Update the preference with the new list
|
||||
pref.setEntries(entries);
|
||||
pref.setEntryValues(entryValues);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void resetBitrateToDefault(SharedPreferences prefs, String res, String fps) {
|
||||
if (res == null) {
|
||||
res = prefs.getString(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION);
|
||||
}
|
||||
if (fps == null) {
|
||||
fps = prefs.getString(PreferenceConfiguration.FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS);
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
.putInt(PreferenceConfiguration.BITRATE_PREF_STRING,
|
||||
PreferenceConfiguration.getDefaultBitrate(res, fps))
|
||||
.apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = super.onCreateView(inflater, container, savedInstanceState);
|
||||
UiHelper.applyStatusBarPadding(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -98,18 +141,44 @@ public class StreamSettings extends Activity {
|
||||
// hide on-screen controls category on non touch screen devices
|
||||
if (!getActivity().getPackageManager().
|
||||
hasSystemFeature("android.hardware.touchscreen")) {
|
||||
PreferenceCategory category =
|
||||
(PreferenceCategory) findPreference("category_onscreen_controls");
|
||||
screen.removePreference(category);
|
||||
{
|
||||
PreferenceCategory category =
|
||||
(PreferenceCategory) findPreference("category_onscreen_controls");
|
||||
screen.removePreference(category);
|
||||
}
|
||||
|
||||
{
|
||||
PreferenceCategory category =
|
||||
(PreferenceCategory) findPreference("category_input_settings");
|
||||
category.removePreference(findPreference("checkbox_touchscreen_trackpad"));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove PiP mode on devices pre-Oreo
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// Remove PiP mode on devices pre-Oreo, where the feature is not available (some low RAM devices),
|
||||
// and on Fire OS where it violates the Amazon App Store guidelines for some reason.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
!getActivity().getPackageManager().hasSystemFeature("android.software.picture_in_picture") ||
|
||||
getActivity().getPackageManager().hasSystemFeature("com.amazon.software.fireos")) {
|
||||
PreferenceCategory category =
|
||||
(PreferenceCategory) findPreference("category_basic_settings");
|
||||
(PreferenceCategory) findPreference("category_ui_settings");
|
||||
category.removePreference(findPreference("checkbox_enable_pip"));
|
||||
}
|
||||
|
||||
// Remove the vibration options if the device can't vibrate
|
||||
if (!((Vibrator)getActivity().getSystemService(Context.VIBRATOR_SERVICE)).hasVibrator()) {
|
||||
PreferenceCategory category =
|
||||
(PreferenceCategory) findPreference("category_input_settings");
|
||||
category.removePreference(findPreference("checkbox_vibrate_fallback"));
|
||||
|
||||
// The entire OSC category may have already been removed by the touchscreen check above
|
||||
category = (PreferenceCategory) findPreference("category_onscreen_controls");
|
||||
if (category != null) {
|
||||
category.removePreference(findPreference("checkbox_vibrate_osc"));
|
||||
}
|
||||
}
|
||||
|
||||
int maxSupportedFps = 0;
|
||||
|
||||
// Hide non-supported resolution/FPS combinations
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Display display = getActivity().getWindowManager().getDefaultDisplay();
|
||||
@@ -134,9 +203,16 @@ public class StreamSettings extends Activity {
|
||||
if ((width >= 3840 || height >= 2160) && maxSupportedResW < 3840) {
|
||||
maxSupportedResW = 3840;
|
||||
}
|
||||
else if ((width >= 2560 || height >= 1440) && maxSupportedResW < 2560) {
|
||||
maxSupportedResW = 2560;
|
||||
}
|
||||
else if ((width >= 1920 || height >= 1080) && maxSupportedResW < 1920) {
|
||||
maxSupportedResW = 1920;
|
||||
}
|
||||
|
||||
if (candidate.getRefreshRate() > maxSupportedFps) {
|
||||
maxSupportedFps = (int)candidate.getRefreshRate();
|
||||
}
|
||||
}
|
||||
|
||||
// This must be called to do runtime initialization before calling functions that evaluate
|
||||
@@ -186,20 +262,114 @@ public class StreamSettings extends Activity {
|
||||
|
||||
LimeLog.info("Maximum resolution slot: "+maxSupportedResW);
|
||||
|
||||
ListPreference resPref = (ListPreference) findPreference("list_resolution_fps");
|
||||
if (maxSupportedResW != 0) {
|
||||
if (maxSupportedResW < 3840) {
|
||||
// 4K is unsupported
|
||||
removeResolution(resPref, "4K");
|
||||
removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_4K, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||
setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P);
|
||||
resetBitrateToDefault(prefs, null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (maxSupportedResW < 2560) {
|
||||
// 1440p is unsupported
|
||||
removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||
setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P);
|
||||
resetBitrateToDefault(prefs, null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (maxSupportedResW < 1920) {
|
||||
// 1080p is unsupported
|
||||
removeResolution(resPref, "1080p");
|
||||
removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||
setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_720P);
|
||||
resetBitrateToDefault(prefs, null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Never remove 720p
|
||||
}
|
||||
}
|
||||
|
||||
if (!PreferenceConfiguration.readPreferences(this.getActivity()).unlockFps) {
|
||||
// We give some extra room in case the FPS is rounded down
|
||||
if (maxSupportedFps < 118) {
|
||||
removeValue(PreferenceConfiguration.FPS_PREF_STRING, "120", new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||
setValue(PreferenceConfiguration.FPS_PREF_STRING, "90");
|
||||
resetBitrateToDefault(prefs, null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (maxSupportedFps < 88) {
|
||||
// 1080p is unsupported
|
||||
removeValue(PreferenceConfiguration.FPS_PREF_STRING, "90", new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||
setValue(PreferenceConfiguration.FPS_PREF_STRING, "60");
|
||||
resetBitrateToDefault(prefs, null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Never remove 30 FPS or 60 FPS
|
||||
}
|
||||
|
||||
// Android L introduces proper 7.1 surround sound support. Remove the 7.1 option
|
||||
// for earlier versions of Android to prevent AudioTrack initialization issues.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
LimeLog.info("Excluding 7.1 surround sound option based on OS");
|
||||
removeValue(PreferenceConfiguration.AUDIO_CONFIG_PREF_STRING, "71", new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
setValue(PreferenceConfiguration.AUDIO_CONFIG_PREF_STRING, "51");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Android L introduces the drop duplicate behavior of releaseOutputBuffer()
|
||||
// that the unlock FPS option relies on to not massively increase latency.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
LimeLog.info("Excluding unlock FPS toggle based on OS");
|
||||
PreferenceCategory category =
|
||||
(PreferenceCategory) findPreference("category_advanced_settings");
|
||||
category.removePreference(findPreference("checkbox_unlock_fps"));
|
||||
}
|
||||
else {
|
||||
findPreference(PreferenceConfiguration.UNLOCK_FPS_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
|
||||
@Override
|
||||
public boolean onPreferenceChange(Preference preference, Object newValue) {
|
||||
// HACK: We need to let the preference change succeed before reinitializing to ensure
|
||||
// it's reflected in the new layout.
|
||||
final Handler h = new Handler();
|
||||
h.postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// Ensure the activity is still open when this timeout expires
|
||||
StreamSettings settingsActivity = (StreamSettings)SettingsFragment.this.getActivity();
|
||||
if (settingsActivity != null) {
|
||||
settingsActivity.reloadSettings();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Allow the original preference change to take place
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove HDR preference for devices below Nougat
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
LimeLog.info("Excluding HDR toggle based on OS");
|
||||
@@ -213,9 +383,12 @@ public class StreamSettings extends Activity {
|
||||
|
||||
// We must now ensure our display is compatible with HDR10
|
||||
boolean foundHdr10 = false;
|
||||
for (int hdrType : hdrCaps.getSupportedHdrTypes()) {
|
||||
if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) {
|
||||
foundHdr10 = true;
|
||||
if (hdrCaps != null) {
|
||||
// getHdrCapabilities() returns null on Lenovo Lenovo Mirage Solo (vega), Android 8.0
|
||||
for (int hdrType : hdrCaps.getSupportedHdrTypes()) {
|
||||
if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) {
|
||||
foundHdr10 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,18 +402,27 @@ public class StreamSettings extends Activity {
|
||||
|
||||
// Add a listener to the FPS and resolution preference
|
||||
// so the bitrate can be auto-adjusted
|
||||
Preference pref = findPreference(PreferenceConfiguration.RES_FPS_PREF_STRING);
|
||||
pref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
|
||||
findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
|
||||
@Override
|
||||
public boolean onPreferenceChange(Preference preference, Object newValue) {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||
String valueStr = (String) newValue;
|
||||
|
||||
// Write the new bitrate value
|
||||
prefs.edit()
|
||||
.putInt(PreferenceConfiguration.BITRATE_PREF_STRING,
|
||||
PreferenceConfiguration.getDefaultBitrate(valueStr))
|
||||
.apply();
|
||||
resetBitrateToDefault(prefs, valueStr, null);
|
||||
|
||||
// Allow the original preference change to take place
|
||||
return true;
|
||||
}
|
||||
});
|
||||
findPreference(PreferenceConfiguration.FPS_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
|
||||
@Override
|
||||
public boolean onPreferenceChange(Preference preference, Object newValue) {
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity());
|
||||
String valueStr = (String) newValue;
|
||||
|
||||
// Write the new bitrate value
|
||||
resetBitrateToDefault(prefs, null, valueStr);
|
||||
|
||||
// Allow the original preference change to take place
|
||||
return true;
|
||||
|
||||
@@ -3,15 +3,21 @@ package com.limelight.ui;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.SurfaceView;
|
||||
|
||||
public class StreamView extends SurfaceView {
|
||||
private double desiredAspectRatio;
|
||||
private InputCallbacks inputCallbacks;
|
||||
|
||||
public void setDesiredAspectRatio(double aspectRatio) {
|
||||
this.desiredAspectRatio = aspectRatio;
|
||||
}
|
||||
|
||||
public void setInputCallbacks(InputCallbacks callbacks) {
|
||||
this.inputCallbacks = callbacks;
|
||||
}
|
||||
|
||||
public StreamView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
@@ -52,4 +58,29 @@ public class StreamView extends SurfaceView {
|
||||
|
||||
setMeasuredDimension(measuredWidth, measuredHeight);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyPreIme(int keyCode, KeyEvent event) {
|
||||
// This callbacks allows us to override dumb IME behavior like when
|
||||
// Samsung's default keyboard consumes Shift+Space.
|
||||
if (inputCallbacks != null) {
|
||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||
if (inputCallbacks.handleKeyDown(event)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (event.getAction() == KeyEvent.ACTION_UP) {
|
||||
if (inputCallbacks.handleKeyUp(event)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.onKeyPreIme(keyCode, event);
|
||||
}
|
||||
|
||||
public interface InputCallbacks {
|
||||
boolean handleKeyUp(KeyEvent event);
|
||||
boolean handleKeyDown(KeyEvent event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.io.OutputStream;
|
||||
import java.io.Reader;
|
||||
|
||||
public class CacheHelper {
|
||||
private static File openPath(boolean createPath, File root, String... path) {
|
||||
public static File openPath(boolean createPath, File root, String... path) {
|
||||
File f = root;
|
||||
for (int i = 0; i < path.length; i++) {
|
||||
String component = path[i];
|
||||
|
||||
@@ -3,41 +3,22 @@ package com.limelight.utils;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.net.Uri;
|
||||
|
||||
import com.limelight.HelpActivity;
|
||||
|
||||
public class HelpLauncher {
|
||||
|
||||
private static boolean isKnownBrowser(Context context, Intent i) {
|
||||
ResolveInfo resolvedActivity = context.getPackageManager().resolveActivity(i, PackageManager.MATCH_DEFAULT_ONLY);
|
||||
if (resolvedActivity == null) {
|
||||
// No browser
|
||||
return false;
|
||||
}
|
||||
|
||||
String name = resolvedActivity.activityInfo.name;
|
||||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
name = name.toLowerCase();
|
||||
return name.contains("chrome") || name.contains("firefox");
|
||||
}
|
||||
|
||||
private static void launchUrl(Context context, String url) {
|
||||
// Try to launch the default browser
|
||||
try {
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setData(Uri.parse(url));
|
||||
|
||||
// Several Android TV devices will lie and say they do have a browser
|
||||
// even though the OS just shows an error dialog if we try to use it. We need to
|
||||
// be a bit more clever on these devices and detect if the browser is a legitimate
|
||||
// browser or just a fake error message activity.
|
||||
if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
|
||||
isKnownBrowser(context, i)) {
|
||||
// Several Android TV devices will lie and say they do have a browser even though the OS
|
||||
// just shows an error dialog if we try to use it. We used to try to be clever and check
|
||||
// the package name of the resolved intent, but it's not worth it anymore with Android 11's
|
||||
// package visibility changes. We'll just always use the WebView on Android TV.
|
||||
if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) {
|
||||
context.startActivity(i);
|
||||
return;
|
||||
}
|
||||
|
||||
32
app/src/main/java/com/limelight/utils/NetHelper.java
Normal file
32
app/src/main/java/com/limelight/utils/NetHelper.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.limelight.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.NetworkInfo;
|
||||
import android.os.Build;
|
||||
|
||||
public class NetHelper {
|
||||
public static boolean isActiveNetworkVpn(Context context) {
|
||||
ConnectivityManager connMgr = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Network activeNetwork = connMgr.getActiveNetwork();
|
||||
if (activeNetwork != null) {
|
||||
NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(activeNetwork);
|
||||
if (netCaps != null) {
|
||||
return netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ||
|
||||
!netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo();
|
||||
if (activeNetworkInfo != null) {
|
||||
return activeNetworkInfo.getType() == ConnectivityManager.TYPE_VPN;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,50 @@ import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.limelight.AppView;
|
||||
import com.limelight.Game;
|
||||
import com.limelight.PcView;
|
||||
import com.limelight.R;
|
||||
import com.limelight.ShortcutTrampoline;
|
||||
import com.limelight.binding.PlatformBinding;
|
||||
import com.limelight.computers.ComputerManagerService;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.GfeHttpResponseException;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
import com.limelight.nvstream.http.NvHTTP;
|
||||
import com.limelight.nvstream.jni.MoonBridge;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
|
||||
public class ServerHelper {
|
||||
public static final String CONNECTION_TEST_SERVER = "android.conntest.moonlight-stream.org";
|
||||
|
||||
public static String getCurrentAddressFromComputer(ComputerDetails computer) {
|
||||
return computer.reachability == ComputerDetails.Reachability.LOCAL ?
|
||||
computer.localAddress : computer.remoteAddress;
|
||||
return computer.activeAddress;
|
||||
}
|
||||
|
||||
public static Intent createPcShortcutIntent(Activity parent, ComputerDetails computer) {
|
||||
Intent i = new Intent(parent, ShortcutTrampoline.class);
|
||||
i.putExtra(AppView.NAME_EXTRA, computer.name);
|
||||
i.putExtra(AppView.UUID_EXTRA, computer.uuid);
|
||||
i.setAction(Intent.ACTION_DEFAULT);
|
||||
return i;
|
||||
}
|
||||
|
||||
public static Intent createAppShortcutIntent(Activity parent, ComputerDetails computer, NvApp app) {
|
||||
Intent i = new Intent(parent, ShortcutTrampoline.class);
|
||||
i.putExtra(AppView.NAME_EXTRA, computer.name);
|
||||
i.putExtra(AppView.UUID_EXTRA, computer.uuid);
|
||||
i.putExtra(Game.EXTRA_APP_NAME, app.getAppName());
|
||||
i.putExtra(Game.EXTRA_APP_ID, ""+app.getAppId());
|
||||
i.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported());
|
||||
i.setAction(Intent.ACTION_DEFAULT);
|
||||
return i;
|
||||
}
|
||||
|
||||
public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer,
|
||||
@@ -30,20 +58,66 @@ public class ServerHelper {
|
||||
intent.putExtra(Game.EXTRA_APP_ID, app.getAppId());
|
||||
intent.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported());
|
||||
intent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId());
|
||||
intent.putExtra(Game.EXTRA_STREAMING_REMOTE,
|
||||
computer.reachability != ComputerDetails.Reachability.LOCAL);
|
||||
intent.putExtra(Game.EXTRA_PC_UUID, computer.uuid.toString());
|
||||
intent.putExtra(Game.EXTRA_PC_UUID, computer.uuid);
|
||||
intent.putExtra(Game.EXTRA_PC_NAME, computer.name);
|
||||
try {
|
||||
if (computer.serverCert != null) {
|
||||
intent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded());
|
||||
}
|
||||
} catch (CertificateEncodingException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static void doStart(Activity parent, NvApp app, ComputerDetails computer,
|
||||
ComputerManagerService.ComputerManagerBinder managerBinder) {
|
||||
if (computer.state == ComputerDetails.State.OFFLINE ||
|
||||
ServerHelper.getCurrentAddressFromComputer(computer) == null) {
|
||||
Toast.makeText(parent, parent.getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
parent.startActivity(createStartIntent(parent, app, computer, managerBinder));
|
||||
}
|
||||
|
||||
public static void doNetworkTest(final Activity parent) {
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
SpinnerDialog spinnerDialog = SpinnerDialog.displayDialog(parent,
|
||||
parent.getResources().getString(R.string.nettest_title_waiting),
|
||||
parent.getResources().getString(R.string.nettest_text_waiting),
|
||||
false);
|
||||
|
||||
int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL);
|
||||
spinnerDialog.dismiss();
|
||||
|
||||
String dialogSummary;
|
||||
if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) {
|
||||
dialogSummary = parent.getResources().getString(R.string.nettest_text_inconclusive);
|
||||
}
|
||||
else if (ret == 0) {
|
||||
dialogSummary = parent.getResources().getString(R.string.nettest_text_success);
|
||||
}
|
||||
else {
|
||||
dialogSummary = parent.getResources().getString(R.string.nettest_text_failure);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
if ((ret & (1 << i)) != 0) {
|
||||
dialogSummary += MoonBridge.getProtocolFromPortFlagIndex(i) + " " + MoonBridge.getPortFromPortFlagIndex(i) + "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog.displayDialog(parent,
|
||||
parent.getResources().getString(R.string.nettest_title_done),
|
||||
dialogSummary,
|
||||
false);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
public static void doQuit(final Activity parent,
|
||||
final String address,
|
||||
final ComputerDetails computer,
|
||||
final NvApp app,
|
||||
final ComputerManagerService.ComputerManagerBinder managerBinder,
|
||||
final Runnable onComplete) {
|
||||
@@ -54,8 +128,8 @@ public class ServerHelper {
|
||||
NvHTTP httpConn;
|
||||
String message;
|
||||
try {
|
||||
httpConn = new NvHTTP(address,
|
||||
managerBinder.getUniqueId(), null, PlatformBinding.getCryptoProvider(parent));
|
||||
httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
|
||||
managerBinder.getUniqueId(), computer.serverCert, PlatformBinding.getCryptoProvider(parent));
|
||||
if (httpConn.quitApp()) {
|
||||
message = parent.getResources().getString(R.string.applist_quit_success) + " " + app.getAppName();
|
||||
} else {
|
||||
@@ -74,8 +148,9 @@ public class ServerHelper {
|
||||
message = parent.getResources().getString(R.string.error_unknown_host);
|
||||
} catch (FileNotFoundException e) {
|
||||
message = parent.getResources().getString(R.string.error_404);
|
||||
} catch (Exception e) {
|
||||
} catch (IOException | XmlPullParserException e) {
|
||||
message = e.getMessage();
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
if (onComplete != null) {
|
||||
onComplete.run();
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
package com.limelight.utils;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ShortcutInfo;
|
||||
import android.content.pm.ShortcutManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.Icon;
|
||||
import android.os.Build;
|
||||
|
||||
import com.limelight.AppView;
|
||||
import com.limelight.AppViewShortcutTrampoline;
|
||||
import com.limelight.ShortcutTrampoline;
|
||||
import com.limelight.R;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
@@ -20,9 +22,10 @@ import java.util.List;
|
||||
public class ShortcutHelper {
|
||||
|
||||
private final ShortcutManager sm;
|
||||
private final Context context;
|
||||
private final Activity context;
|
||||
private final TvChannelHelper tvChannelHelper;
|
||||
|
||||
public ShortcutHelper(Context context) {
|
||||
public ShortcutHelper(Activity context) {
|
||||
this.context = context;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
sm = context.getSystemService(ShortcutManager.class);
|
||||
@@ -30,6 +33,7 @@ public class ShortcutHelper {
|
||||
else {
|
||||
sm = null;
|
||||
}
|
||||
this.tvChannelHelper = new TvChannelHelper(context);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.N_MR1)
|
||||
@@ -78,40 +82,39 @@ public class ShortcutHelper {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void reportShortcutUsed(String id) {
|
||||
public void reportComputerShortcutUsed(ComputerDetails computer) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
ShortcutInfo sinfo = getInfoForId(id);
|
||||
if (sinfo != null) {
|
||||
sm.reportShortcutUsed(id);
|
||||
if (getInfoForId(computer.uuid) != null) {
|
||||
sm.reportShortcutUsed(computer.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void createAppViewShortcut(String id, String computerName, String computerUuid, boolean forceAdd) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
Intent i = new Intent(context, AppViewShortcutTrampoline.class);
|
||||
i.putExtra(AppView.NAME_EXTRA, computerName);
|
||||
i.putExtra(AppView.UUID_EXTRA, computerUuid);
|
||||
i.setAction(Intent.ACTION_DEFAULT);
|
||||
public void reportGameLaunched(ComputerDetails computer, NvApp app) {
|
||||
tvChannelHelper.createTvChannel(computer);
|
||||
tvChannelHelper.addGameToChannel(computer, app);
|
||||
}
|
||||
|
||||
ShortcutInfo sinfo = new ShortcutInfo.Builder(context, id)
|
||||
.setIntent(i)
|
||||
.setShortLabel(computerName)
|
||||
.setLongLabel(computerName)
|
||||
public void createAppViewShortcut(ComputerDetails computer, boolean forceAdd, boolean newlyPaired) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
ShortcutInfo sinfo = new ShortcutInfo.Builder(context, computer.uuid)
|
||||
.setIntent(ServerHelper.createPcShortcutIntent(context, computer))
|
||||
.setShortLabel(computer.name)
|
||||
.setLongLabel(computer.name)
|
||||
.setIcon(Icon.createWithResource(context, R.mipmap.ic_pc_scut))
|
||||
.build();
|
||||
|
||||
ShortcutInfo existingSinfo = getInfoForId(id);
|
||||
ShortcutInfo existingSinfo = getInfoForId(computer.uuid);
|
||||
if (existingSinfo != null) {
|
||||
// Update in place
|
||||
sm.updateShortcuts(Collections.singletonList(sinfo));
|
||||
sm.enableShortcuts(Collections.singletonList(id));
|
||||
sm.enableShortcuts(Collections.singletonList(computer.uuid));
|
||||
}
|
||||
|
||||
// Reap shortcuts to make space for this if it's new
|
||||
// NOTE: This CAN'T be an else on the above if, because it's
|
||||
// possible that we have an existing shortcut but it's not a dynamic one.
|
||||
if (!isExistingDynamicShortcut(id)) {
|
||||
if (!isExistingDynamicShortcut(computer.uuid)) {
|
||||
// To avoid a random carousel of shortcuts popping in and out based on polling status,
|
||||
// we only add shortcuts if it's not at the limit or the user made a conscious action
|
||||
// to interact with this PC.
|
||||
@@ -121,18 +124,81 @@ public class ShortcutHelper {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newlyPaired) {
|
||||
// Avoid hammering the channel API for each computer poll because it will throttle us
|
||||
tvChannelHelper.createTvChannel(computer);
|
||||
tvChannelHelper.requestChannelOnHomeScreen(computer);
|
||||
}
|
||||
}
|
||||
|
||||
public void createAppViewShortcut(String id, ComputerDetails details, boolean forceAdd) {
|
||||
createAppViewShortcut(id, details.name, details.uuid.toString(), forceAdd);
|
||||
public void createAppViewShortcutForOnlineHost(ComputerDetails details) {
|
||||
createAppViewShortcut(details, false, false);
|
||||
}
|
||||
|
||||
public void disableShortcut(String id, CharSequence reason) {
|
||||
private String getShortcutIdForGame(ComputerDetails computer, NvApp app) {
|
||||
return computer.uuid + app.getAppId();
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
public boolean createPinnedGameShortcut(ComputerDetails computer, NvApp app, Bitmap iconBits) {
|
||||
if (sm.isRequestPinShortcutSupported()) {
|
||||
Icon appIcon;
|
||||
|
||||
if (iconBits != null) {
|
||||
appIcon = Icon.createWithAdaptiveBitmap(iconBits);
|
||||
} else {
|
||||
appIcon = Icon.createWithResource(context, R.mipmap.ic_pc_scut);
|
||||
}
|
||||
|
||||
ShortcutInfo sInfo = new ShortcutInfo.Builder(context, getShortcutIdForGame(computer, app))
|
||||
.setIntent(ServerHelper.createAppShortcutIntent(context, computer, app))
|
||||
.setShortLabel(app.getAppName() + " (" + computer.name + ")")
|
||||
.setIcon(appIcon)
|
||||
.build();
|
||||
|
||||
return sm.requestPinShortcut(sInfo, null);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void disableComputerShortcut(ComputerDetails computer, CharSequence reason) {
|
||||
tvChannelHelper.deleteChannel(computer);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
ShortcutInfo sinfo = getInfoForId(id);
|
||||
if (sinfo != null) {
|
||||
// Delete the computer shortcut itself
|
||||
if (getInfoForId(computer.uuid) != null) {
|
||||
sm.disableShortcuts(Collections.singletonList(computer.uuid), reason);
|
||||
}
|
||||
|
||||
// Delete all associated app shortcuts too
|
||||
List<ShortcutInfo> shortcuts = getAllShortcuts();
|
||||
LinkedList<String> appShortcutIds = new LinkedList<>();
|
||||
for (ShortcutInfo info : shortcuts) {
|
||||
if (info.getId().startsWith(computer.uuid)) {
|
||||
appShortcutIds.add(info.getId());
|
||||
}
|
||||
}
|
||||
sm.disableShortcuts(appShortcutIds, reason);
|
||||
}
|
||||
}
|
||||
|
||||
public void disableAppShortcut(ComputerDetails computer, NvApp app, CharSequence reason) {
|
||||
tvChannelHelper.deleteProgram(computer, app);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
String id = getShortcutIdForGame(computer, app);
|
||||
if (getInfoForId(id) != null) {
|
||||
sm.disableShortcuts(Collections.singletonList(id), reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void enableAppShortcut(ComputerDetails computer, NvApp app) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
String id = getShortcutIdForGame(computer, app);
|
||||
if (getInfoForId(id) != null) {
|
||||
sm.enableShortcuts(Collections.singletonList(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
345
app/src/main/java/com/limelight/utils/TvChannelHelper.java
Normal file
345
app/src/main/java/com/limelight/utils/TvChannelHelper.java
Normal file
@@ -0,0 +1,345 @@
|
||||
package com.limelight.utils;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteException;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.media.tv.TvContract;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
|
||||
import com.limelight.LimeLog;
|
||||
import com.limelight.PosterContentProvider;
|
||||
import com.limelight.R;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.nvstream.http.NvApp;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
public class TvChannelHelper {
|
||||
|
||||
private static final int ASPECT_RATIO_MOVIE_POSTER = 5;
|
||||
private static final int TYPE_GAME = 12;
|
||||
private static final int INTERNAL_PROVIDER_ID_INDEX = 1;
|
||||
private static final int PROGRAM_BROWSABLE_INDEX = 2;
|
||||
private static final int ID_INDEX = 0;
|
||||
private Activity context;
|
||||
|
||||
public TvChannelHelper(Activity context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
void requestChannelOnHomeScreen(ComputerDetails computer) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (!isAndroidTV()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long channelId = getChannelId(computer.uuid);
|
||||
if (channelId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Intent intent = new Intent(TvContract.ACTION_REQUEST_CHANNEL_BROWSABLE);
|
||||
intent.putExtra(TvContract.EXTRA_CHANNEL_ID, getChannelId(computer.uuid));
|
||||
try {
|
||||
context.startActivityForResult(intent, 0);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void createTvChannel(ComputerDetails computer) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (!isAndroidTV()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ChannelBuilder builder = new ChannelBuilder()
|
||||
.setType(TvContract.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(computer.name)
|
||||
.setInternalProviderId(computer.uuid)
|
||||
.setAppLinkIntent(ServerHelper.createPcShortcutIntent(context, computer));
|
||||
|
||||
Long channelId = getChannelId(computer.uuid);
|
||||
if (channelId != null) {
|
||||
context.getContentResolver().update(TvContract.buildChannelUri(channelId),
|
||||
builder.toContentValues(), null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
Uri channelUri = context.getContentResolver().insert(
|
||||
TvContract.Channels.CONTENT_URI, builder.toContentValues());
|
||||
if (channelUri != null) {
|
||||
long id = ContentUris.parseId(channelUri);
|
||||
updateChannelIcon(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
private void updateChannelIcon(long channelId) {
|
||||
Bitmap logo = drawableToBitmap(context.getResources().getDrawable(R.drawable.ic_channel));
|
||||
try {
|
||||
Uri localUri = TvContract.buildChannelLogoUri(channelId);
|
||||
try (OutputStream outputStream = context.getContentResolver().openOutputStream(localUri)) {
|
||||
logo.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
|
||||
outputStream.flush();
|
||||
} catch (SQLiteException | IOException e) {
|
||||
LimeLog.warning("Failed to store the logo to the system content provider.");
|
||||
e.printStackTrace();
|
||||
}
|
||||
} finally {
|
||||
logo.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap drawableToBitmap(Drawable drawable) {
|
||||
int width = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width);
|
||||
int height = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width);
|
||||
|
||||
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(bitmap);
|
||||
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
|
||||
drawable.draw(canvas);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
void addGameToChannel(ComputerDetails computer, NvApp app) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (!isAndroidTV()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Long channelId = getChannelId(computer.uuid);
|
||||
if (channelId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PreviewProgramBuilder builder = new PreviewProgramBuilder()
|
||||
.setChannelId(channelId)
|
||||
.setType(TYPE_GAME)
|
||||
.setTitle(app.getAppName())
|
||||
.setPosterArtAspectRatio(ASPECT_RATIO_MOVIE_POSTER)
|
||||
.setPosterArtUri(PosterContentProvider.createBoxArtUri(computer.uuid, ""+app.getAppId()))
|
||||
.setIntent(ServerHelper.createAppShortcutIntent(context, computer, app))
|
||||
.setInternalProviderId(""+app.getAppId())
|
||||
// Weight should increase each time we run the game
|
||||
.setWeight((int)((System.currentTimeMillis() - 1500000000000L) / 1000));
|
||||
|
||||
Long programId = getProgramId(channelId, ""+app.getAppId());
|
||||
if (programId != null) {
|
||||
context.getContentResolver().update(TvContract.buildPreviewProgramUri(programId),
|
||||
builder.toContentValues(), null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
context.getContentResolver().insert(TvContract.PreviewPrograms.CONTENT_URI,
|
||||
builder.toContentValues());
|
||||
|
||||
TvContract.requestChannelBrowsable(context, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
void deleteChannel(ComputerDetails computer) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (!isAndroidTV()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long channelId = getChannelId(computer.uuid);
|
||||
if (channelId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.getContentResolver().delete(TvContract.buildChannelUri(channelId), null, null);
|
||||
}
|
||||
}
|
||||
|
||||
void deleteProgram(ComputerDetails computer, NvApp app) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (!isAndroidTV()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long channelId = getChannelId(computer.uuid);
|
||||
if (channelId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Long programId = getProgramId(channelId, ""+app.getAppId());
|
||||
if (programId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.getContentResolver().delete(TvContract.buildPreviewProgramUri(programId), null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
private Long getChannelId(String computerUuid) {
|
||||
try (Cursor cursor = context.getContentResolver().query(
|
||||
TvContract.Channels.CONTENT_URI,
|
||||
new String[] {TvContract.Channels._ID, TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID},
|
||||
null,
|
||||
null,
|
||||
null)) {
|
||||
if (cursor == null || cursor.getCount() == 0) {
|
||||
return null;
|
||||
}
|
||||
while (cursor.moveToNext()) {
|
||||
String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX);
|
||||
if (computerUuid.equals(internalProviderId)) {
|
||||
return cursor.getLong(ID_INDEX);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
private Long getProgramId(long channelId, String appId) {
|
||||
try (Cursor cursor = context.getContentResolver().query(
|
||||
TvContract.buildPreviewProgramsUriForChannel(channelId),
|
||||
new String[] {TvContract.PreviewPrograms._ID, TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, TvContract.PreviewPrograms.COLUMN_BROWSABLE},
|
||||
null,
|
||||
null,
|
||||
null)) {
|
||||
if (cursor == null || cursor.getCount() == 0) {
|
||||
return null;
|
||||
}
|
||||
while (cursor.moveToNext()) {
|
||||
String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX);
|
||||
if (appId.equals(internalProviderId)) {
|
||||
long id = cursor.getLong(ID_INDEX);
|
||||
int browsable = cursor.getInt(PROGRAM_BROWSABLE_INDEX);
|
||||
if (browsable != 0) {
|
||||
return id;
|
||||
} else {
|
||||
int countDeleted = context.getContentResolver().delete(TvContract.buildPreviewProgramUri(id), null, null);
|
||||
if (countDeleted > 0) {
|
||||
LimeLog.info("Preview program has been deleted");
|
||||
} else {
|
||||
LimeLog.warning("Preview program has not been deleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> String toValueString(T value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private static String toUriString(Intent intent) {
|
||||
return intent == null ? null : intent.toUri(Intent.URI_INTENT_SCHEME);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
private boolean isAndroidTV() {
|
||||
return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
private static class PreviewProgramBuilder {
|
||||
|
||||
private ContentValues mValues = new ContentValues();
|
||||
|
||||
|
||||
public PreviewProgramBuilder setChannelId(Long channelId) {
|
||||
mValues.put(TvContract.PreviewPrograms.COLUMN_CHANNEL_ID, channelId);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PreviewProgramBuilder setType(int type) {
|
||||
mValues.put(TvContract.PreviewPrograms.COLUMN_TYPE, type);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PreviewProgramBuilder setTitle(String title) {
|
||||
mValues.put(TvContract.PreviewPrograms.COLUMN_TITLE, title);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PreviewProgramBuilder setPosterArtAspectRatio(int aspectRatio) {
|
||||
mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_ASPECT_RATIO, aspectRatio);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PreviewProgramBuilder setIntent(Intent intent) {
|
||||
mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toUriString(intent));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PreviewProgramBuilder setIntentUri(Uri uri) {
|
||||
mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toValueString(uri));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PreviewProgramBuilder setInternalProviderId(String id) {
|
||||
mValues.put(TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, id);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PreviewProgramBuilder setPosterArtUri(Uri uri) {
|
||||
mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_URI, toValueString(uri));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PreviewProgramBuilder setWeight(int weight) {
|
||||
mValues.put(TvContract.PreviewPrograms.COLUMN_WEIGHT, weight);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContentValues toContentValues() {
|
||||
return new ContentValues(mValues);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
private static class ChannelBuilder {
|
||||
|
||||
private ContentValues mValues = new ContentValues();
|
||||
|
||||
public ChannelBuilder setType(String type) {
|
||||
mValues.put(TvContract.Channels.COLUMN_TYPE, type);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChannelBuilder setDisplayName(String displayName) {
|
||||
mValues.put(TvContract.Channels.COLUMN_DISPLAY_NAME, displayName);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChannelBuilder setInternalProviderId(String internalProviderId) {
|
||||
mValues.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID, internalProviderId);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChannelBuilder setAppLinkIntent(Intent intent) {
|
||||
mValues.put(TvContract.Channels.COLUMN_APP_LINK_INTENT_URI, toUriString(intent));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContentValues toContentValues() {
|
||||
return new ContentValues(mValues);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,22 @@ import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Insets;
|
||||
import android.os.Build;
|
||||
import android.view.View;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import com.limelight.R;
|
||||
import com.limelight.nvstream.http.ComputerDetails;
|
||||
import com.limelight.preferences.PreferenceConfiguration;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class UiHelper {
|
||||
|
||||
// Values from https://developer.android.com/training/tv/start/layouts.html
|
||||
private static final int TV_VERTICAL_PADDING_DP = 27;
|
||||
private static final int TV_HORIZONTAL_PADDING_DP = 48;
|
||||
private static final int TV_VERTICAL_PADDING_DP = 15;
|
||||
private static final int TV_HORIZONTAL_PADDING_DP = 15;
|
||||
|
||||
public static void setLocale(Activity activity)
|
||||
{
|
||||
@@ -42,13 +46,39 @@ public class UiHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public static void notifyNewRootView(Activity activity)
|
||||
public static void applyStatusBarPadding(View view) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// This applies the padding that we omitted in notifyNewRootView() on Q
|
||||
view.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() {
|
||||
@Override
|
||||
public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) {
|
||||
view.setPadding(view.getPaddingLeft(),
|
||||
view.getPaddingTop(),
|
||||
view.getPaddingRight(),
|
||||
windowInsets.getTappableElementInsets().bottom);
|
||||
return windowInsets;
|
||||
}
|
||||
});
|
||||
view.requestApplyInsets();
|
||||
}
|
||||
}
|
||||
|
||||
public static void notifyNewRootView(final Activity activity)
|
||||
{
|
||||
View rootView = activity.findViewById(android.R.id.content);
|
||||
UiModeManager modeMgr = (UiModeManager) activity.getSystemService(Context.UI_MODE_SERVICE);
|
||||
|
||||
if (modeMgr.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
// Allow this non-streaming activity to layout under notches.
|
||||
//
|
||||
// We should NOT do this for the Game activity unless
|
||||
// the user specifically opts in, because it can obscure
|
||||
// parts of the streaming surface.
|
||||
activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
|
||||
}
|
||||
|
||||
if (modeMgr.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) {
|
||||
// Increase view padding on TVs
|
||||
float scale = activity.getResources().getDisplayMetrics().density;
|
||||
int verticalPaddingPixels = (int) (TV_VERTICAL_PADDING_DP*scale + 0.5f);
|
||||
@@ -57,6 +87,34 @@ public class UiHelper {
|
||||
rootView.setPadding(horizontalPaddingPixels, verticalPaddingPixels,
|
||||
horizontalPaddingPixels, verticalPaddingPixels);
|
||||
}
|
||||
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Draw under the status bar on Android Q devices
|
||||
|
||||
// Using getDecorView() here breaks the translucent status/navigation bar when gestures are disabled
|
||||
activity.findViewById(android.R.id.content).setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() {
|
||||
@Override
|
||||
public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) {
|
||||
// Use the tappable insets so we can draw under the status bar in gesture mode
|
||||
Insets tappableInsets = windowInsets.getTappableElementInsets();
|
||||
view.setPadding(tappableInsets.left,
|
||||
tappableInsets.top,
|
||||
tappableInsets.right,
|
||||
0);
|
||||
|
||||
// Show a translucent navigation bar if we can't tap there
|
||||
if (tappableInsets.bottom != 0) {
|
||||
activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
|
||||
}
|
||||
else {
|
||||
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
|
||||
}
|
||||
|
||||
return windowInsets;
|
||||
}
|
||||
});
|
||||
|
||||
activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
|
||||
}
|
||||
}
|
||||
|
||||
public static void showDecoderCrashDialog(Activity activity) {
|
||||
@@ -123,4 +181,32 @@ public class UiHelper {
|
||||
.setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener)
|
||||
.show();
|
||||
}
|
||||
|
||||
public static void displayDeletePcConfirmationDialog(Activity parent, ComputerDetails computer, final Runnable onYes, final Runnable onNo) {
|
||||
DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
switch (which){
|
||||
case DialogInterface.BUTTON_POSITIVE:
|
||||
if (onYes != null) {
|
||||
onYes.run();
|
||||
}
|
||||
break;
|
||||
|
||||
case DialogInterface.BUTTON_NEGATIVE:
|
||||
if (onNo != null) {
|
||||
onNo.run();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(parent);
|
||||
builder.setMessage(parent.getResources().getString(R.string.delete_pc_msg))
|
||||
.setTitle(computer.name)
|
||||
.setPositiveButton(parent.getResources().getString(R.string.yes), dialogClickListener)
|
||||
.setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
package com.limelight.utils;
|
||||
|
||||
public class Vector2d {
|
||||
private float x;
|
||||
private float y;
|
||||
private double magnitude;
|
||||
|
||||
public static final Vector2d ZERO = new Vector2d();
|
||||
|
||||
public Vector2d() {
|
||||
initialize(0, 0);
|
||||
}
|
||||
|
||||
public void initialize(float x, float y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.magnitude = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
|
||||
}
|
||||
|
||||
public double getMagnitude() {
|
||||
return magnitude;
|
||||
}
|
||||
|
||||
public void getNormalized(Vector2d vector) {
|
||||
vector.initialize((float)(x / magnitude), (float)(y / magnitude));
|
||||
}
|
||||
|
||||
public void scalarMultiply(double factor) {
|
||||
initialize((float)(x * factor), (float)(y * factor));
|
||||
}
|
||||
|
||||
public void setX(float x) {
|
||||
initialize(x, this.y);
|
||||
}
|
||||
|
||||
public void setY(float y) {
|
||||
initialize(this.x, y);
|
||||
}
|
||||
|
||||
public float getX() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public float getY() {
|
||||
return y;
|
||||
}
|
||||
private float x;
|
||||
private float y;
|
||||
private double magnitude;
|
||||
|
||||
public static final Vector2d ZERO = new Vector2d();
|
||||
|
||||
public Vector2d() {
|
||||
initialize(0, 0);
|
||||
}
|
||||
|
||||
public void initialize(float x, float y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.magnitude = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
|
||||
}
|
||||
|
||||
public double getMagnitude() {
|
||||
return magnitude;
|
||||
}
|
||||
|
||||
public void getNormalized(Vector2d vector) {
|
||||
vector.initialize((float)(x / magnitude), (float)(y / magnitude));
|
||||
}
|
||||
|
||||
public void scalarMultiply(double factor) {
|
||||
initialize((float)(x * factor), (float)(y * factor));
|
||||
}
|
||||
|
||||
public void setX(float x) {
|
||||
initialize(x, this.y);
|
||||
}
|
||||
|
||||
public void setY(float y) {
|
||||
initialize(this.x, y);
|
||||
}
|
||||
|
||||
public float getX() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public float getY() {
|
||||
return y;
|
||||
}
|
||||
}
|
||||
|
||||
59
app/src/main/jni/moonlight-core/Android.mk
Normal file
59
app/src/main/jni/moonlight-core/Android.mk
Normal file
@@ -0,0 +1,59 @@
|
||||
# Android.mk for moonlight-core and binding
|
||||
MY_LOCAL_PATH := $(call my-dir)
|
||||
|
||||
include $(call all-subdir-makefiles)
|
||||
|
||||
LOCAL_PATH := $(MY_LOCAL_PATH)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := moonlight-core
|
||||
|
||||
LOCAL_SRC_FILES := moonlight-common-c/src/AudioStream.c \
|
||||
moonlight-common-c/src/ByteBuffer.c \
|
||||
moonlight-common-c/src/Connection.c \
|
||||
moonlight-common-c/src/ConnectionTester.c \
|
||||
moonlight-common-c/src/ControlStream.c \
|
||||
moonlight-common-c/src/FakeCallbacks.c \
|
||||
moonlight-common-c/src/InputStream.c \
|
||||
moonlight-common-c/src/LinkedBlockingQueue.c \
|
||||
moonlight-common-c/src/Misc.c \
|
||||
moonlight-common-c/src/Platform.c \
|
||||
moonlight-common-c/src/PlatformSockets.c \
|
||||
moonlight-common-c/src/RtpFecQueue.c \
|
||||
moonlight-common-c/src/RtpReorderQueue.c \
|
||||
moonlight-common-c/src/RtspConnection.c \
|
||||
moonlight-common-c/src/RtspParser.c \
|
||||
moonlight-common-c/src/SdpGenerator.c \
|
||||
moonlight-common-c/src/SimpleStun.c \
|
||||
moonlight-common-c/src/VideoDepacketizer.c \
|
||||
moonlight-common-c/src/VideoStream.c \
|
||||
moonlight-common-c/reedsolomon/rs.c \
|
||||
moonlight-common-c/enet/callbacks.c \
|
||||
moonlight-common-c/enet/compress.c \
|
||||
moonlight-common-c/enet/host.c \
|
||||
moonlight-common-c/enet/list.c \
|
||||
moonlight-common-c/enet/packet.c \
|
||||
moonlight-common-c/enet/peer.c \
|
||||
moonlight-common-c/enet/protocol.c \
|
||||
moonlight-common-c/enet/unix.c \
|
||||
moonlight-common-c/enet/win32.c \
|
||||
simplejni.c \
|
||||
callbacks.c \
|
||||
|
||||
|
||||
LOCAL_C_INCLUDES := $(LOCAL_PATH)/moonlight-common-c/enet/include \
|
||||
$(LOCAL_PATH)/moonlight-common-c/reedsolomon \
|
||||
$(LOCAL_PATH)/moonlight-common-c/src \
|
||||
|
||||
LOCAL_CFLAGS := -DHAS_SOCKLEN_T=1 -DLC_ANDROID -DHAVE_CLOCK_GETTIME=1
|
||||
|
||||
ifeq ($(NDK_DEBUG),1)
|
||||
LOCAL_CFLAGS += -DLC_DEBUG
|
||||
endif
|
||||
|
||||
LOCAL_LDLIBS := -llog
|
||||
|
||||
LOCAL_STATIC_LIBRARIES := libopus libssl libcrypto
|
||||
LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL
|
||||
|
||||
include $(BUILD_SHARED_LIBRARY)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user