mirror of
https://github.com/revanced/revanced-patches.git
synced 2025-12-08 18:33:57 +01:00
Compare commits
724 Commits
v5.5.0-dev
...
v5.30.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3c9dc6ed7 | ||
|
|
d7ed32571f | ||
|
|
d3935f03c0 | ||
|
|
b2e601f0f0 | ||
|
|
d3ec219a29 | ||
|
|
5ed07d4aaa | ||
|
|
209a3a3626 | ||
|
|
2b3419571f | ||
|
|
bbe504e616 | ||
|
|
6c32591f62 | ||
|
|
ad6da67281 | ||
|
|
14dc593eba | ||
|
|
e52ee41222 | ||
|
|
6ee94f8532 | ||
|
|
21688201af | ||
|
|
f08474369b | ||
|
|
ed617094ea | ||
|
|
9131c50f1b | ||
|
|
69600d08a4 | ||
|
|
5dba77612b | ||
|
|
92b588c866 | ||
|
|
da20e565cd | ||
|
|
ca694c78d2 | ||
|
|
e169056b70 | ||
|
|
b6bf1e026c | ||
|
|
9fa89d48c0 | ||
|
|
5d2c21540c | ||
|
|
1a8aacdff6 | ||
|
|
1804bd9bfc | ||
|
|
7eb4e62762 | ||
|
|
b8e10b5c1f | ||
|
|
a7c11b9b08 | ||
|
|
443c0a74d5 | ||
|
|
84a0f7f7d7 | ||
|
|
558bf8bca8 | ||
|
|
e22d4e6a4b | ||
|
|
a07f946633 | ||
|
|
29c86ac6a3 | ||
|
|
19cf5667d8 | ||
|
|
fb83e58f79 | ||
|
|
9844081d04 | ||
|
|
439ca37e99 | ||
|
|
113a3d9f19 | ||
|
|
978c24458b | ||
|
|
957bece3e9 | ||
|
|
d32c3ac51d | ||
|
|
26102a70a2 | ||
|
|
2b44bf4c23 | ||
|
|
0e63f49e13 | ||
|
|
674a5b8d29 | ||
|
|
7be374100b | ||
|
|
e48c152b95 | ||
|
|
a678f178e1 | ||
|
|
2d8f5641f9 | ||
|
|
0dbd058099 | ||
|
|
c1a8fd0766 | ||
|
|
d338989cb4 | ||
|
|
b94daacf01 | ||
|
|
c764c4f197 | ||
|
|
6b719dfcd7 | ||
|
|
ccd169121a | ||
|
|
dcfbd8bf93 | ||
|
|
b65697603d | ||
|
|
25da5cca8b | ||
|
|
2b62fc2224 | ||
|
|
a9e9456b6b | ||
|
|
b01523e97d | ||
|
|
b8afb4e821 | ||
|
|
0d2198faed | ||
|
|
5c7c407b82 | ||
|
|
a8d2a1e028 | ||
|
|
d31624cae8 | ||
|
|
e790cfbf59 | ||
|
|
a54d408d3e | ||
|
|
5d3769e921 | ||
|
|
e138501657 | ||
|
|
a9235d6b62 | ||
|
|
90868ff025 | ||
|
|
345ec5c430 | ||
|
|
c8b95d475c | ||
|
|
0a93f44a5e | ||
|
|
a426e2af50 | ||
|
|
9e30c34e74 | ||
|
|
55401368b8 | ||
|
|
c0c56fef23 | ||
|
|
69df47602f | ||
|
|
1cea6bfdff | ||
|
|
e2a9552f91 | ||
|
|
7bbaca77ad | ||
|
|
246f3efe55 | ||
|
|
0fca3e8fb1 | ||
|
|
c09255eaed | ||
|
|
e78d6240ea | ||
|
|
ed0d807d70 | ||
|
|
1c39004350 | ||
|
|
6127f48a9e | ||
|
|
ad416f4aa7 | ||
|
|
adfac8a1f2 | ||
|
|
498488d45b | ||
|
|
f8e31c820a | ||
|
|
826a391591 | ||
|
|
af827e2f1a | ||
|
|
97cd31509e | ||
|
|
c0448dece4 | ||
|
|
f00a95c0d8 | ||
|
|
7a432e5741 | ||
|
|
966a78bd81 | ||
|
|
6aff8e8ca4 | ||
|
|
11aa463fa6 | ||
|
|
bf1b639a2f | ||
|
|
6d5380d44d | ||
|
|
7e1547b5b9 | ||
|
|
c790b45cc5 | ||
|
|
65fc6b43f5 | ||
|
|
2257dd90aa | ||
|
|
4b8499ff2c | ||
|
|
bde3fda972 | ||
|
|
e2e07b5cb2 | ||
|
|
9d10ab6c00 | ||
|
|
d7644152fd | ||
|
|
9be21f4824 | ||
|
|
a2eae0bf04 | ||
|
|
679354b5b3 | ||
|
|
91dec21033 | ||
|
|
1d0c56819b | ||
|
|
4410816c22 | ||
|
|
7e4e48bc9f | ||
|
|
e435b33593 | ||
|
|
bf288b83ae | ||
|
|
7a53580380 | ||
|
|
6439efa2a9 | ||
|
|
bc45433dcb | ||
|
|
8871803e83 | ||
|
|
18954a0285 | ||
|
|
ce5385b28e | ||
|
|
3f4cdf6f83 | ||
|
|
094b4a1ea8 | ||
|
|
a320e35c32 | ||
|
|
5bf5a2d2db | ||
|
|
ff903ba9ac | ||
|
|
1079a54dbe | ||
|
|
2b0e3b4553 | ||
|
|
0265a7791b | ||
|
|
49ae0df224 | ||
|
|
e279491724 | ||
|
|
495260fe2b | ||
|
|
40f069fff7 | ||
|
|
de263c1061 | ||
|
|
bf1f26d8bb | ||
|
|
0ee2ed72d4 | ||
|
|
02373b0bd2 | ||
|
|
97c8e2489d | ||
|
|
08b2b2e104 | ||
|
|
6b386b67d2 | ||
|
|
f8343ae9f6 | ||
|
|
3ba791ac7d | ||
|
|
443b54bf09 | ||
|
|
53587f190d | ||
|
|
83c148addc | ||
|
|
5c8ed05727 | ||
|
|
33833d7a1e | ||
|
|
b712f38017 | ||
|
|
517368eda7 | ||
|
|
2093c0c175 | ||
|
|
a7cfd80bfe | ||
|
|
2990dc6d4e | ||
|
|
c0e52bb6b3 | ||
|
|
93fdd6f538 | ||
|
|
decd249f20 | ||
|
|
d79cb3eea8 | ||
|
|
584b00fd87 | ||
|
|
795016abce | ||
|
|
dc1dbd50a8 | ||
|
|
2984d7362d | ||
|
|
627aed4010 | ||
|
|
4ab1f0cfa9 | ||
|
|
86e8e61ab2 | ||
|
|
e286dab74e | ||
|
|
712a82439f | ||
|
|
4449546c85 | ||
|
|
8d61ba90c3 | ||
|
|
689be79f71 | ||
|
|
b6047fa6b3 | ||
|
|
82bbd603ac | ||
|
|
bc0c3c452d | ||
|
|
fe864d8331 | ||
|
|
4f686935c3 | ||
|
|
798596fd83 | ||
|
|
38b37f182a | ||
|
|
52b9dc5c9f | ||
|
|
dea7108c45 | ||
|
|
24b4579cb9 | ||
|
|
0b52f3d192 | ||
|
|
18c374a81e | ||
|
|
092303e431 | ||
|
|
6bf5bf9d45 | ||
|
|
b2b09a2025 | ||
|
|
4a3a7f1674 | ||
|
|
e59c9e9b3c | ||
|
|
dfb552b01a | ||
|
|
94999c56b1 | ||
|
|
c4fd1f0146 | ||
|
|
4cd0ae9b92 | ||
|
|
9548d581c1 | ||
|
|
a2fe3af6be | ||
|
|
6ef6504d41 | ||
|
|
e58290839f | ||
|
|
e18260bd65 | ||
|
|
b2fcd5a846 | ||
|
|
e68cd70f66 | ||
|
|
14a8f4fb96 | ||
|
|
2593c004f4 | ||
|
|
db68c41d5e | ||
|
|
a4f9cb3cef | ||
|
|
9aec1999bb | ||
|
|
26ecbe646e | ||
|
|
46ba0d8a2e | ||
|
|
f454183646 | ||
|
|
d2b440d800 | ||
|
|
494c5f04a4 | ||
|
|
48d5fdf7e1 | ||
|
|
887c9f0d75 | ||
|
|
7de4c9d41d | ||
|
|
7d3b8d9c42 | ||
|
|
25e1a965d6 | ||
|
|
b29c01cee1 | ||
|
|
639850471b | ||
|
|
796c118fe1 | ||
|
|
edf20e397d | ||
|
|
5f0541407c | ||
|
|
56b7ba9ba7 | ||
|
|
f8bdf744ab | ||
|
|
f4f36ff273 | ||
|
|
5028c1acb3 | ||
|
|
555c9a5823 | ||
|
|
777957e2d0 | ||
|
|
b3316a5915 | ||
|
|
2ca2bb7692 | ||
|
|
23fd720fa7 | ||
|
|
1f08586ae8 | ||
|
|
60fdf4c44c | ||
|
|
63f3342815 | ||
|
|
858c59d728 | ||
|
|
5debf9936d | ||
|
|
f1b85d20a1 | ||
|
|
37d0de5e93 | ||
|
|
96d08d5eb7 | ||
|
|
9b1013e1c2 | ||
|
|
75d6cd7c7b | ||
|
|
5a17f5e1c1 | ||
|
|
1d16de6617 | ||
|
|
aee7cba46d | ||
|
|
ec3faf30a8 | ||
|
|
45b5a51da3 | ||
|
|
8abf176bc9 | ||
|
|
ef35ed7335 | ||
|
|
4fd666b667 | ||
|
|
72e0c01922 | ||
|
|
f69eab3e3b | ||
|
|
7c5c2d95bc | ||
|
|
b2453fecfc | ||
|
|
0d54f8bd80 | ||
|
|
fda16fad1a | ||
|
|
ddd43acd73 | ||
|
|
3451318d53 | ||
|
|
2d94ba9df6 | ||
|
|
aaf3437a5a | ||
|
|
ec8bf06047 | ||
|
|
96512de6c9 | ||
|
|
6114807c43 | ||
|
|
6d69f01421 | ||
|
|
fd4218154d | ||
|
|
8bed8a6622 | ||
|
|
3174047223 | ||
|
|
15053e2b68 | ||
|
|
e5b6aac018 | ||
|
|
d7c9dd0f77 | ||
|
|
a0eb6d5fdb | ||
|
|
55c5eb3d14 | ||
|
|
896de8910a | ||
|
|
e2a7e25c66 | ||
|
|
77ea5c4033 | ||
|
|
6eea2354f5 | ||
|
|
cce21c4d4a | ||
|
|
5e069bde90 | ||
|
|
6a49208982 | ||
|
|
404bb2e86e | ||
|
|
bc869fe359 | ||
|
|
7d166cf82c | ||
|
|
8efbaae65c | ||
|
|
e27ab23279 | ||
|
|
ce42604083 | ||
|
|
fc6282d0cb | ||
|
|
0559fc7fd0 | ||
|
|
7cc6995682 | ||
|
|
476f13bf98 | ||
|
|
f216e16c0b | ||
|
|
f2a8789649 | ||
|
|
5973b64f52 | ||
|
|
102036706e | ||
|
|
2393d0a8f5 | ||
|
|
aea29b9522 | ||
|
|
4db8ef7079 | ||
|
|
7fbd26ccad | ||
|
|
91995ea01d | ||
|
|
86f867fe97 | ||
|
|
0f687ecfd3 | ||
|
|
6c8b7d09c1 | ||
|
|
3d6958f157 | ||
|
|
43d7cc7374 | ||
|
|
5ebd449f1f | ||
|
|
346a061df8 | ||
|
|
13e490a422 | ||
|
|
b4e8540bbc | ||
|
|
775c1baec2 | ||
|
|
9419fb8ec4 | ||
|
|
c510931eb0 | ||
|
|
7160699384 | ||
|
|
9db67a6eb2 | ||
|
|
e684d87dd3 | ||
|
|
2d1752a1eb | ||
|
|
c9ff7092fe | ||
|
|
d451bc6d6d | ||
|
|
741fd36872 | ||
|
|
517f8cf59a | ||
|
|
b78fb24435 | ||
|
|
a3faccb21b | ||
|
|
5f0fddc122 | ||
|
|
854a18ff72 | ||
|
|
b994a16bdc | ||
|
|
f68d06dbf3 | ||
|
|
04c6a2e5f4 | ||
|
|
e6ae55fa99 | ||
|
|
fb62474ff4 | ||
|
|
e084f01fd0 | ||
|
|
d573386e0f | ||
|
|
0f3aeb35e5 | ||
|
|
e30f593af0 | ||
|
|
df965b8a9b | ||
|
|
654587a75e | ||
|
|
9956833781 | ||
|
|
c585b26188 | ||
|
|
de0d11fcfb | ||
|
|
d321504fcf | ||
|
|
6005c97bf5 | ||
|
|
e404d84c83 | ||
|
|
1abed31968 | ||
|
|
a75a88d3c6 | ||
|
|
3d67d90473 | ||
|
|
fa1e137a43 | ||
|
|
ac71a53c73 | ||
|
|
0bff207efc | ||
|
|
e1a8b388a5 | ||
|
|
628d18489c | ||
|
|
36772b8b2e | ||
|
|
49c849979f | ||
|
|
0bdb8cdf2b | ||
|
|
2035c9e2e9 | ||
|
|
7cb38fd3fc | ||
|
|
8ed9d5bf08 | ||
|
|
cd467d6244 | ||
|
|
fdefb67d02 | ||
|
|
5274cd18f0 | ||
|
|
3d68c06146 | ||
|
|
ef3d5bafd5 | ||
|
|
2d7b1b09af | ||
|
|
0572d48fde | ||
|
|
37984b8b99 | ||
|
|
6e63193f06 | ||
|
|
b2384b22a5 | ||
|
|
ccb76983ff | ||
|
|
318b55b8fe | ||
|
|
49ade9efbc | ||
|
|
d77515bd68 | ||
|
|
087bf1e152 | ||
|
|
c2994d583d | ||
|
|
127b0a63fe | ||
|
|
27aafd0ee1 | ||
|
|
49c54c0e54 | ||
|
|
842ba4fc4d | ||
|
|
66ecadce4f | ||
|
|
73ca04da5e | ||
|
|
a5d26208c1 | ||
|
|
497291c478 | ||
|
|
b24278a544 | ||
|
|
135f9ead3c | ||
|
|
ca4f960171 | ||
|
|
7f228cc535 | ||
|
|
bf91e127d8 | ||
|
|
f07fc1ad93 | ||
|
|
c84be120bd | ||
|
|
e67f390e2b | ||
|
|
4d910fea93 | ||
|
|
72adbe5519 | ||
|
|
54d49b774e | ||
|
|
283bb31567 | ||
|
|
2724fcbd27 | ||
|
|
7c28193579 | ||
|
|
cd1ee814c4 | ||
|
|
d9ccd73b5f | ||
|
|
5c5a1e4b8b | ||
|
|
66a2ee2416 | ||
|
|
d8c276cf96 | ||
|
|
d5845abd08 | ||
|
|
54eef22ce7 | ||
|
|
e287bdc59d | ||
|
|
20a82ef956 | ||
|
|
1e29da9e06 | ||
|
|
56e6a90a90 | ||
|
|
76d32e21c2 | ||
|
|
54a7afa540 | ||
|
|
ef86438bac | ||
|
|
0683cedac0 | ||
|
|
35753410aa | ||
|
|
df838ed91d | ||
|
|
8e494d26d4 | ||
|
|
7d834e5421 | ||
|
|
60a31cf4e1 | ||
|
|
edb8bd66bc | ||
|
|
04a170054e | ||
|
|
79e6349a69 | ||
|
|
bbf3a34a2f | ||
|
|
1db7c49514 | ||
|
|
ef0506a4f8 | ||
|
|
9b38da35ff | ||
|
|
afdb771066 | ||
|
|
1b2b536d2e | ||
|
|
f39e70c648 | ||
|
|
556acdd9c1 | ||
|
|
7adfc637dc | ||
|
|
9cc0c075ad | ||
|
|
ead11e7f46 | ||
|
|
e9bc201641 | ||
|
|
99baedf355 | ||
|
|
0338d0acd3 | ||
|
|
99879f6e0a | ||
|
|
f0c70de602 | ||
|
|
737ae07a06 | ||
|
|
2c51de59de | ||
|
|
df3dc1c0b2 | ||
|
|
074c948581 | ||
|
|
2a88b1f895 | ||
|
|
ee5c830df8 | ||
|
|
e63a4b31f3 | ||
|
|
8d0bca3b03 | ||
|
|
c162d65d5b | ||
|
|
67dcd091c4 | ||
|
|
ac5ce2d67f | ||
|
|
4b78d056fd | ||
|
|
f8c901b2c1 | ||
|
|
2a67c312e1 | ||
|
|
a7eed30f46 | ||
|
|
e2de2d8d44 | ||
|
|
7ebbf356c0 | ||
|
|
2ced5c6e2a | ||
|
|
4a090ba659 | ||
|
|
cb609a6d9d | ||
|
|
42e6de9e8f | ||
|
|
c4a5b9a28c | ||
|
|
c86c85947f | ||
|
|
cbbf474c50 | ||
|
|
f147b7b73d | ||
|
|
fb8dbb4723 | ||
|
|
1e0d27e689 | ||
|
|
a2185bce09 | ||
|
|
1b60a72ede | ||
|
|
12b4ee04ad | ||
|
|
f9a6cc96de | ||
|
|
93ea250bf3 | ||
|
|
fdb946a2cc | ||
|
|
7cc939ab03 | ||
|
|
228d72428d | ||
|
|
4db7ab4207 | ||
|
|
329f993024 | ||
|
|
7cd1fb22d8 | ||
|
|
ae111bc0b9 | ||
|
|
79f1dfd3e8 | ||
|
|
f5dd902915 | ||
|
|
10e2b08eb2 | ||
|
|
4ae1155e51 | ||
|
|
69fbfaea19 | ||
|
|
f44fede67c | ||
|
|
3c52ab8017 | ||
|
|
d1641a6e3d | ||
|
|
09773e8934 | ||
|
|
d77d5bfbdd | ||
|
|
a84bded9e7 | ||
|
|
e664a24f73 | ||
|
|
5bf964fff6 | ||
|
|
0c0bbb8713 | ||
|
|
8afe48cd92 | ||
|
|
dde8ea31cb | ||
|
|
d3abbe3e93 | ||
|
|
c8179776ed | ||
|
|
c6c6516b12 | ||
|
|
d6eae01e12 | ||
|
|
ba88603f4b | ||
|
|
d5aab3d464 | ||
|
|
fca2f70c0e | ||
|
|
348f7e12cb | ||
|
|
b6b7208eeb | ||
|
|
a2c79f1349 | ||
|
|
4f5bb3c915 | ||
|
|
4b77d27c77 | ||
|
|
7991c80129 | ||
|
|
6baf4ea2ac | ||
|
|
c89538c8f5 | ||
|
|
94fb367618 | ||
|
|
354835966d | ||
|
|
168f9b769e | ||
|
|
e4c4b3a73a | ||
|
|
fce98b4960 | ||
|
|
839aa81e9c | ||
|
|
905bb0ea5f | ||
|
|
a94a663859 | ||
|
|
04b37dd55a | ||
|
|
2382e9d09e | ||
|
|
97f504976a | ||
|
|
0a6c5158e0 | ||
|
|
a959d798e8 | ||
|
|
39a0b9bda6 | ||
|
|
92c38b2cb4 | ||
|
|
4732210d4b | ||
|
|
f30a49f1cb | ||
|
|
bcd157dd2b | ||
|
|
d299ea5973 | ||
|
|
a20021e290 | ||
|
|
373ca966f3 | ||
|
|
12de922afa | ||
|
|
580bb3cf6c | ||
|
|
421af92f4c | ||
|
|
4d03e1b5a1 | ||
|
|
24d68df6cd | ||
|
|
e9aee17746 | ||
|
|
7c4285e3e6 | ||
|
|
e3110271a7 | ||
|
|
0079eceb87 | ||
|
|
af2a97cb16 | ||
|
|
aeb552e8f2 | ||
|
|
6e936fea42 | ||
|
|
f63769f39f | ||
|
|
1c9ab20a63 | ||
|
|
cdeccad908 | ||
|
|
399889c6fa | ||
|
|
ec77861410 | ||
|
|
b5afc6d827 | ||
|
|
b7ebfddf65 | ||
|
|
2742aca48b | ||
|
|
14ca4d3288 | ||
|
|
a06c0318bf | ||
|
|
7f9f668435 | ||
|
|
76fd33ca54 | ||
|
|
9a653e9c5a | ||
|
|
f81b658fb7 | ||
|
|
7ff39d89d6 | ||
|
|
78ab0ec2bd | ||
|
|
3ab67f1539 | ||
|
|
8652cd613f | ||
|
|
bc8388713c | ||
|
|
d4b2e3be3e | ||
|
|
57c48b7829 | ||
|
|
aaa7523ee4 | ||
|
|
785df4fe69 | ||
|
|
83208eb50d | ||
|
|
9437db11eb | ||
|
|
1843c8bf70 | ||
|
|
778b51fbff | ||
|
|
ee0fdcdf86 | ||
|
|
57cc73d9c4 | ||
|
|
043ebbb6d4 | ||
|
|
d5551923fc | ||
|
|
f844a1cd76 | ||
|
|
a7e3277cc1 | ||
|
|
6fa2deea69 | ||
|
|
dcca2a3697 | ||
|
|
018160fd9c | ||
|
|
680252967e | ||
|
|
e79eba81d9 | ||
|
|
a73db03671 | ||
|
|
055ad04281 | ||
|
|
aaeee4a895 | ||
|
|
654b339f66 | ||
|
|
64cdce28a6 | ||
|
|
d01b9a67c5 | ||
|
|
a72404eeab | ||
|
|
3ff104528e | ||
|
|
76bbd7ed2f | ||
|
|
2fdf0f85c1 | ||
|
|
1d12c4156d | ||
|
|
c43050dce8 | ||
|
|
8104bbd7d7 | ||
|
|
8487888e6b | ||
|
|
6721a284cd | ||
|
|
6cde702854 | ||
|
|
7c8efcaf41 | ||
|
|
350ee02e3b | ||
|
|
df2d070a43 | ||
|
|
8167aaccc8 | ||
|
|
f4989ed0a5 | ||
|
|
8f5a0531bc | ||
|
|
622554de14 | ||
|
|
66e330ffe6 | ||
|
|
2afcd3d63d | ||
|
|
80d7c78cf6 | ||
|
|
d85bcc3c16 | ||
|
|
21368ea696 | ||
|
|
e687d3ed37 | ||
|
|
064b859d39 | ||
|
|
89882ddaf8 | ||
|
|
41881ba161 | ||
|
|
0615990138 | ||
|
|
70532313db | ||
|
|
e5e897de77 | ||
|
|
1e57ce9658 | ||
|
|
fcad0ab5bb | ||
|
|
91471eccf9 | ||
|
|
d559f016c6 | ||
|
|
5a82d26f03 | ||
|
|
e2eae499d9 | ||
|
|
64919d6443 | ||
|
|
c6ffaf86ae | ||
|
|
3ee99b7bf1 | ||
|
|
6f9bf4873f | ||
|
|
29a73089a3 | ||
|
|
74ef1841eb | ||
|
|
0c544d28e3 | ||
|
|
b1e5b99b44 | ||
|
|
7b90baadb5 | ||
|
|
4a6f3c8555 | ||
|
|
e7c6943ca7 | ||
|
|
ae1b987c0d | ||
|
|
9496438da1 | ||
|
|
fa51631ea6 | ||
|
|
8bf7108001 | ||
|
|
030eece04a | ||
|
|
30009b723d | ||
|
|
53b25ea7e9 | ||
|
|
189e1c90c4 | ||
|
|
f01603b3f3 | ||
|
|
3db5651e5c | ||
|
|
f3c4d6fd64 | ||
|
|
29dbc9ffbf | ||
|
|
fa4aa54f0c | ||
|
|
1d89ada07f | ||
|
|
8c529abad5 | ||
|
|
4ade7c7329 | ||
|
|
f35247a872 | ||
|
|
4de768febf | ||
|
|
1a5c86db93 | ||
|
|
dbba795468 | ||
|
|
0a9320551d | ||
|
|
9fac1614e7 | ||
|
|
2de3523c59 | ||
|
|
ad1e40b130 | ||
|
|
094a6aa6de | ||
|
|
a14e03e4bb | ||
|
|
6f40b6d30f | ||
|
|
1711e1c39d | ||
|
|
25372828d1 | ||
|
|
f58245c6cd | ||
|
|
87e1c7f4c8 | ||
|
|
55d01c92d1 | ||
|
|
ca21a69550 | ||
|
|
634d0b4058 | ||
|
|
47ea8d5ec8 | ||
|
|
9509ed53f3 | ||
|
|
39542ddf55 | ||
|
|
e1741130af | ||
|
|
e54eb3ce87 | ||
|
|
0ae756b0fc | ||
|
|
77a0ac5c9c | ||
|
|
899121b9de | ||
|
|
838edb48e7 | ||
|
|
b2665c916a | ||
|
|
4b81f7009b | ||
|
|
1a4c39a2ee | ||
|
|
99334d1e53 | ||
|
|
2850a6ed4e | ||
|
|
f28eb5105b | ||
|
|
69bed4d9fa | ||
|
|
a5f1efac27 | ||
|
|
b51be82cff | ||
|
|
b8635d0b88 | ||
|
|
78699c8bbf | ||
|
|
aeedec7fed | ||
|
|
32b614696b | ||
|
|
a0b63dfa23 | ||
|
|
f0f53cf72f | ||
|
|
cdb68209d1 | ||
|
|
7369f7b8d5 | ||
|
|
db521b940b | ||
|
|
25d7cc68ae | ||
|
|
9495064e6e | ||
|
|
64864c2cdb | ||
|
|
ad0ffb3328 | ||
|
|
06800324aa | ||
|
|
ec746cb05a | ||
|
|
67c5530ea6 | ||
|
|
cd08717783 | ||
|
|
7bac023ea5 | ||
|
|
1d0ec98bec | ||
|
|
3c603fac2d | ||
|
|
20a7ad4715 | ||
|
|
25a60e305e | ||
|
|
c7f42d9a3c | ||
|
|
670f100a29 | ||
|
|
19140e5918 | ||
|
|
1dde485013 | ||
|
|
5efcdd31c8 | ||
|
|
e6529837cb | ||
|
|
fe07033444 | ||
|
|
246333f3dc | ||
|
|
d82b02e4f5 | ||
|
|
44995a9f15 | ||
|
|
c87c788a26 | ||
|
|
4ef30618d1 | ||
|
|
b23e6c39fc | ||
|
|
de26766543 | ||
|
|
9168b5eaaf | ||
|
|
c43b9b3b03 | ||
|
|
5e8dfed3e8 | ||
|
|
d67dbba76f | ||
|
|
5dc93156e0 | ||
|
|
5275413ab7 |
6
.github/workflows/build_pull_request.yml
vendored
6
.github/workflows/build_pull_request.yml
vendored
@@ -19,11 +19,11 @@ jobs:
|
|||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: 'temurin'
|
||||||
java-version: "17"
|
java-version: '17'
|
||||||
|
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: burrunan/gradle-cache-action@v1
|
uses: burrunan/gradle-cache-action@v3
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
|
|||||||
24
.github/workflows/pull_strings.yml
vendored
24
.github/workflows/pull_strings.yml
vendored
@@ -1,6 +1,8 @@
|
|||||||
name: Pull strings
|
name: Pull strings
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 */12 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -14,23 +16,29 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
|
||||||
ref: dev
|
ref: dev
|
||||||
|
fetch-depth: 0
|
||||||
|
clean: true
|
||||||
|
|
||||||
- name: Pull strings
|
- name: Pull strings
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@v2
|
||||||
with:
|
with:
|
||||||
config: crowdin.yml
|
config: crowdin.yml
|
||||||
|
upload_sources: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
skip_ref_checkout: true
|
||||||
localization_branch_name: feat/translations
|
localization_branch_name: feat/translations
|
||||||
create_pull_request: true
|
create_pull_request: false
|
||||||
pull_request_title: "chore: Sync translations"
|
|
||||||
pull_request_body: "Sync translations from [crowdin.com/project/revanced](https://crowdin.com/project/revanced)"
|
|
||||||
pull_request_base_branch_name: "dev"
|
|
||||||
commit_message: "chore: Sync translations"
|
|
||||||
github_user_name: revanced-bot
|
|
||||||
github_user_email: github@revanced.app
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
|
|
||||||
|
- name: Open pull request
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
uses: repo-sync/pull-request@v2
|
||||||
|
with:
|
||||||
|
source_branch: feat/translations
|
||||||
|
destination_branch: dev
|
||||||
|
pr_title: "chore: Sync translations"
|
||||||
|
pr_body: "Sync translations from [crowdin.com/project/revanced](https://crowdin.com/project/revanced)"
|
||||||
|
|||||||
5
.github/workflows/push_strings.yml
vendored
5
.github/workflows/push_strings.yml
vendored
@@ -18,6 +18,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Preprocess strings
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: ./gradlew clean preprocessCrowdinStrings
|
||||||
|
|
||||||
- name: Push strings
|
- name: Push strings
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
23
.github/workflows/release.yml
vendored
23
.github/workflows/release.yml
vendored
@@ -13,24 +13,23 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# Make sure the release step uses its own credentials:
|
|
||||||
# https://github.com/cycjimmy/semantic-release-action#private-packages
|
|
||||||
persist-credentials: false
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: 'temurin'
|
||||||
java-version: "17"
|
java-version: '17'
|
||||||
|
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: burrunan/gradle-cache-action@v1
|
uses: burrunan/gradle-cache-action@v3
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
@@ -40,7 +39,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "lts/*"
|
node-version: 'lts/*'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -54,6 +53,14 @@ jobs:
|
|||||||
fingerprint: ${{ vars.GPG_FINGERPRINT }}
|
fingerprint: ${{ vars.GPG_FINGERPRINT }}
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
|
uses: cycjimmy/semantic-release-action@v4
|
||||||
|
id: release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: npm exec semantic-release
|
|
||||||
|
- name: Attest
|
||||||
|
if: steps.release.outputs.new_release_published == 'true'
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: 'ReVanced Patches ${{ steps.release.outputs.new_release_git_tag }}'
|
||||||
|
subject-path: patches/build/libs/patches-*.rvp
|
||||||
|
|||||||
10
.releaserc
10
.releaserc
@@ -22,7 +22,7 @@
|
|||||||
{
|
{
|
||||||
"assets": [
|
"assets": [
|
||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
"gradle.properties",
|
"gradle.properties"
|
||||||
],
|
],
|
||||||
"message": "chore: Release v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
"message": "chore: Release v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||||
}
|
}
|
||||||
@@ -33,16 +33,16 @@
|
|||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"path": "patches/build/libs/patches-!(*sources*|*javadoc*).rvp?(.asc)"
|
"path": "patches/build/libs/patches-!(*sources*|*javadoc*).rvp?(.asc)"
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
successComment: false
|
"successComment": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"@saithodev/semantic-release-backmerge",
|
"@saithodev/semantic-release-backmerge",
|
||||||
{
|
{
|
||||||
backmergeBranches: [{"from": "main", "to": "dev"}],
|
"backmergeBranches": [{"from": "main", "to": "dev"}],
|
||||||
clearWorkspace: true
|
"clearWorkspace": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
2239
CHANGELOG.md
2239
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
3
build.gradle.kts
Normal file
3
build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
}
|
||||||
16
extensions/all/misc/adb/hide-adb/build.gradle.kts
Normal file
16
extensions/all/misc/adb/hide-adb/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
android {
|
||||||
|
namespace = "app.revanced.extension"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(libs.annotation)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<manifest/>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package app.revanced.extension.all.misc.hide.adb;
|
||||||
|
|
||||||
|
import android.content.ContentResolver;
|
||||||
|
import android.provider.Settings;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public final class HideAdbPatch {
|
||||||
|
private static final List<String> SPOOF_SETTINGS = Arrays.asList("adb_enabled", "adb_wifi_enabled", "development_settings_enabled");
|
||||||
|
|
||||||
|
public static int getInt(ContentResolver cr, String name) throws Settings.SettingNotFoundException {
|
||||||
|
if (SPOOF_SETTINGS.contains(name)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Settings.Global.getInt(cr, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int getInt(ContentResolver cr, String name, int def) {
|
||||||
|
if (SPOOF_SETTINGS.contains(name)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Settings.Global.getInt(cr, name, def);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
android.namespace = "app.revanced.extension"
|
android {
|
||||||
|
namespace = "app.revanced.extension"
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly(libs.annotation)
|
compileOnly(libs.annotation)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package app.revanced.extension.all.connectivity.wifi.spoof;
|
package app.revanced.extension.all.misc.connectivity.wifi.spoof;
|
||||||
|
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
@@ -12,7 +12,7 @@ import android.os.Handler;
|
|||||||
|
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
|
|
||||||
/** @noinspection deprecation, unused */
|
@SuppressWarnings({"deprecation", "unused"})
|
||||||
public class SpoofWifiPatch {
|
public class SpoofWifiPatch {
|
||||||
|
|
||||||
// Used to check what the (real or fake) active network is (take a look at `hasTransport`).
|
// Used to check what the (real or fake) active network is (take a look at `hasTransport`).
|
||||||
@@ -1,3 +1,16 @@
|
|||||||
|
android {
|
||||||
|
namespace = "app.revanced.extension"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly(libs.annotation)
|
compileOnly(libs.annotation)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.revanced.extension.all.misc.directory.documentsprovider;
|
package app.revanced.extension.all.misc.directory.documentsprovider;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.pm.ApplicationInfo;
|
import android.content.pm.ApplicationInfo;
|
||||||
import android.content.pm.ProviderInfo;
|
import android.content.pm.ProviderInfo;
|
||||||
@@ -23,6 +24,7 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* A DocumentsProvider that allows access to the app's internal data directory.
|
* A DocumentsProvider that allows access to the app's internal data directory.
|
||||||
*/
|
*/
|
||||||
|
@SuppressLint("LongLogTag")
|
||||||
public class InternalDataDocumentsProvider extends DocumentsProvider {
|
public class InternalDataDocumentsProvider extends DocumentsProvider {
|
||||||
private static final String[] rootColumns =
|
private static final String[] rootColumns =
|
||||||
{"root_id", "mime_types", "flags", "icon", "title", "summary", "document_id"};
|
{"root_id", "mime_types", "flags", "icon", "title", "summary", "document_id"};
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
android.namespace = "app.revanced.extension"
|
android {
|
||||||
|
namespace = "app.revanced.extension"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly(libs.annotation)
|
compileOnly(libs.annotation)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package app.revanced.extension.all.screencapture.removerestriction;
|
package app.revanced.extension.all.misc.screencapture.removerestriction;
|
||||||
|
|
||||||
import android.media.AudioAttributes;
|
import android.media.AudioAttributes;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
|
|
||||||
public final class RemoveScreencaptureRestrictionPatch {
|
@SuppressWarnings("unused")
|
||||||
|
public final class RemoveScreenCaptureRestrictionPatch {
|
||||||
// Member of AudioAttributes.Builder
|
// Member of AudioAttributes.Builder
|
||||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||||
public static AudioAttributes.Builder setAllowedCapturePolicy(final AudioAttributes.Builder builder, final int capturePolicy) {
|
public static AudioAttributes.Builder setAllowedCapturePolicy(final AudioAttributes.Builder builder, final int capturePolicy) {
|
||||||
@@ -1 +1,16 @@
|
|||||||
android.namespace = "app.revanced.extension"
|
android {
|
||||||
|
namespace = "app.revanced.extension"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(libs.annotation)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package app.revanced.extension.all.screenshot.removerestriction;
|
package app.revanced.extension.all.misc.screenshot.removerestriction;
|
||||||
|
|
||||||
import android.view.Window;
|
import android.view.Window;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
public class RemoveScreenshotRestrictionPatch {
|
public class RemoveScreenshotRestrictionPatch {
|
||||||
|
|
||||||
public static void addFlags(Window window, int flags) {
|
public static void addFlags(Window window, int flags) {
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id(libs.plugins.android.library.get().pluginId)
|
alias(libs.plugins.android.library)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "app.revanced.extension"
|
namespace = "app.revanced.extension"
|
||||||
compileSdk = 33
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
|
|||||||
3
extensions/messenger/build.gradle.kts
Normal file
3
extensions/messenger/build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
dependencies {
|
||||||
|
compileOnly(project(":extensions:shared:library"))
|
||||||
|
}
|
||||||
1
extensions/messenger/src/main/AndroidManifest.xml
Normal file
1
extensions/messenger/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest/>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package app.revanced.extension.messenger.metaai;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public class RemoveMetaAIPatch {
|
||||||
|
private static final Set<Long> loggedIDs = Collections.synchronizedSet(new HashSet<>());
|
||||||
|
|
||||||
|
public static boolean overrideBooleanFlag(long id, boolean value) {
|
||||||
|
try {
|
||||||
|
if (Long.toString(id).startsWith("REPLACED_BY_PATCH")) {
|
||||||
|
if (loggedIDs.add(id))
|
||||||
|
Logger.printInfo(() -> "Overriding " + id + " from " + value + " to false");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "overrideBooleanFlag failure", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
// Do not remove. Necessary for the extension plugin to be applied to the project.
|
android {
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
4
extensions/nunl/build.gradle.kts
Normal file
4
extensions/nunl/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dependencies {
|
||||||
|
compileOnly(project(":extensions:shared:library"))
|
||||||
|
compileOnly(project(":extensions:nunl:stub"))
|
||||||
|
}
|
||||||
1
extensions/nunl/src/main/AndroidManifest.xml
Normal file
1
extensions/nunl/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest/>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package app.revanced.extension.nunl.ads;
|
||||||
|
|
||||||
|
import nl.nu.performance.api.client.interfaces.Block;
|
||||||
|
import nl.nu.performance.api.client.unions.SmallArticleLinkFlavor;
|
||||||
|
import nl.nu.performance.api.client.objects.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public class HideAdsPatch {
|
||||||
|
private static final String[] blockedHeaderBlocks = {
|
||||||
|
"Aanbiedingen (Adverteerders)",
|
||||||
|
"Aangeboden door NUshop"
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Rubrieken" menu links to ads.
|
||||||
|
private static final String[] blockedLinkBlocks = {
|
||||||
|
"Van onze adverteerders"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static void filterAds(List<Block> blocks) {
|
||||||
|
try {
|
||||||
|
ArrayList<Block> cleanedList = new ArrayList<>();
|
||||||
|
|
||||||
|
boolean skipFullHeader = false;
|
||||||
|
boolean skipUntilDivider = false;
|
||||||
|
|
||||||
|
int index = 0;
|
||||||
|
while (index < blocks.size()) {
|
||||||
|
Block currentBlock = blocks.get(index);
|
||||||
|
|
||||||
|
// Because of pagination, we might not see the Divider in front of it.
|
||||||
|
// Just remove it as is and leave potential extra spacing visible on the screen.
|
||||||
|
if (currentBlock instanceof DpgBannerBlock) {
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index + 1 < blocks.size()) {
|
||||||
|
// Filter Divider -> DpgMediaBanner -> Divider.
|
||||||
|
if (currentBlock instanceof DividerBlock
|
||||||
|
&& blocks.get(index + 1) instanceof DpgBannerBlock) {
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Divider -> LinkBlock (... -> LinkBlock -> LinkBlock-> LinkBlock -> Divider).
|
||||||
|
if (currentBlock instanceof DividerBlock
|
||||||
|
&& blocks.get(index + 1) instanceof LinkBlock linkBlock) {
|
||||||
|
Link link = linkBlock.getLink();
|
||||||
|
if (link != null && link.getTitle() != null) {
|
||||||
|
for (String blockedLinkBlock : blockedLinkBlocks) {
|
||||||
|
if (blockedLinkBlock.equals(link.getTitle().getText())) {
|
||||||
|
skipUntilDivider = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (skipUntilDivider) {
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip LinkBlocks with a "flavor" claiming to be "isPartner" (sponsored inline ads).
|
||||||
|
if (currentBlock instanceof LinkBlock linkBlock
|
||||||
|
&& linkBlock.getLink() != null
|
||||||
|
&& linkBlock.getLink().getLinkFlavor() instanceof SmallArticleLinkFlavor smallArticleLinkFlavor
|
||||||
|
&& smallArticleLinkFlavor.isPartner() != null
|
||||||
|
&& smallArticleLinkFlavor.isPartner()) {
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBlock instanceof DividerBlock) {
|
||||||
|
skipUntilDivider = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter HeaderBlock with known ads until next HeaderBlock.
|
||||||
|
if (currentBlock instanceof HeaderBlock headerBlock) {
|
||||||
|
StyledText headerText = headerBlock.getTitle();
|
||||||
|
if (headerText != null) {
|
||||||
|
skipFullHeader = false;
|
||||||
|
for (String blockedHeaderBlock : blockedHeaderBlocks) {
|
||||||
|
if (blockedHeaderBlock.equals(headerText.getText())) {
|
||||||
|
skipFullHeader = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (skipFullHeader) {
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipFullHeader && !skipUntilDivider) {
|
||||||
|
cleanedList.add(currentBlock);
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace list in-place to not deal with moving the result to the correct register in smali.
|
||||||
|
blocks.clear();
|
||||||
|
blocks.addAll(cleanedList);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "filterAds failure", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
extensions/nunl/stub/build.gradle.kts
Normal file
17
extensions/nunl/stub/build.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.revanced.extension"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
1
extensions/nunl/stub/src/main/AndroidManifest.xml
Normal file
1
extensions/nunl/stub/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest/>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package nl.nu.performance.api.client.interfaces;
|
||||||
|
|
||||||
|
public class Block {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package nl.nu.performance.api.client.objects;
|
||||||
|
|
||||||
|
import nl.nu.performance.api.client.interfaces.Block;
|
||||||
|
|
||||||
|
public class DividerBlock extends Block {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package nl.nu.performance.api.client.objects;
|
||||||
|
|
||||||
|
import nl.nu.performance.api.client.interfaces.Block;
|
||||||
|
|
||||||
|
public class DpgBannerBlock extends Block {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package nl.nu.performance.api.client.objects;
|
||||||
|
|
||||||
|
import nl.nu.performance.api.client.interfaces.Block;
|
||||||
|
|
||||||
|
public class HeaderBlock extends Block {
|
||||||
|
public final StyledText getTitle() {
|
||||||
|
throw new UnsupportedOperationException("Stub");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package nl.nu.performance.api.client.objects;
|
||||||
|
|
||||||
|
import nl.nu.performance.api.client.unions.LinkFlavor;
|
||||||
|
|
||||||
|
public class Link {
|
||||||
|
public final StyledText getTitle() {
|
||||||
|
throw new UnsupportedOperationException("Stub");
|
||||||
|
}
|
||||||
|
|
||||||
|
public final LinkFlavor getLinkFlavor() {
|
||||||
|
throw new UnsupportedOperationException("Stub");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package nl.nu.performance.api.client.objects;
|
||||||
|
|
||||||
|
import android.os.Parcelable;
|
||||||
|
import nl.nu.performance.api.client.interfaces.Block;
|
||||||
|
|
||||||
|
public abstract class LinkBlock extends Block implements Parcelable {
|
||||||
|
public final Link getLink() {
|
||||||
|
throw new UnsupportedOperationException("Stub");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package nl.nu.performance.api.client.objects;
|
||||||
|
|
||||||
|
public class StyledText {
|
||||||
|
public final String getText() {
|
||||||
|
throw new UnsupportedOperationException("Stub");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package nl.nu.performance.api.client.unions;
|
||||||
|
|
||||||
|
public interface LinkFlavor {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package nl.nu.performance.api.client.unions;
|
||||||
|
|
||||||
|
public class SmallArticleLinkFlavor implements LinkFlavor {
|
||||||
|
public final Boolean isPartner() {
|
||||||
|
throw new UnsupportedOperationException("Stub");
|
||||||
|
}
|
||||||
|
}
|
||||||
4
extensions/primevideo/build.gradle.kts
Normal file
4
extensions/primevideo/build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dependencies {
|
||||||
|
compileOnly(project(":extensions:shared:library"))
|
||||||
|
compileOnly(project(":extensions:primevideo:stub"))
|
||||||
|
}
|
||||||
1
extensions/primevideo/src/main/AndroidManifest.xml
Normal file
1
extensions/primevideo/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest/>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package app.revanced.extension.primevideo.ads;
|
||||||
|
|
||||||
|
import com.amazon.avod.fsm.SimpleTrigger;
|
||||||
|
import com.amazon.avod.media.ads.AdBreak;
|
||||||
|
import com.amazon.avod.media.ads.internal.state.AdBreakTrigger;
|
||||||
|
import com.amazon.avod.media.ads.internal.state.AdEnabledPlayerTriggerType;
|
||||||
|
import com.amazon.avod.media.playback.VideoPlayer;
|
||||||
|
import com.amazon.avod.media.ads.internal.state.ServerInsertedAdBreakState;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public final class SkipAdsPatch {
|
||||||
|
public static void enterServerInsertedAdBreakState(ServerInsertedAdBreakState state, AdBreakTrigger trigger, VideoPlayer player) {
|
||||||
|
try {
|
||||||
|
AdBreak adBreak = trigger.getBreak();
|
||||||
|
|
||||||
|
// There are two scenarios when entering the original method:
|
||||||
|
// 1. Player naturally entered an ad break while watching a video.
|
||||||
|
// 2. User is skipped/scrubbed to a position on the timeline. If seek position is past an ad break,
|
||||||
|
// user is forced to watch an ad before continuing.
|
||||||
|
//
|
||||||
|
// Scenario 2 is indicated by trigger.getSeekStartPosition() != null, so skip directly to the scrubbing
|
||||||
|
// target. Otherwise, just calculate when the ad break should end and skip to there.
|
||||||
|
if (trigger.getSeekStartPosition() != null)
|
||||||
|
player.seekTo(trigger.getSeekTarget().getTotalMilliseconds());
|
||||||
|
else
|
||||||
|
player.seekTo(player.getCurrentPosition() + adBreak.getDurationExcludingAux().getTotalMilliseconds());
|
||||||
|
|
||||||
|
// Send "end of ads" trigger to state machine so everything doesn't get whacky.
|
||||||
|
state.doTrigger(new SimpleTrigger(AdEnabledPlayerTriggerType.NO_MORE_ADS_SKIP_TRANSITION));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "Failed skipping ads", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
extensions/primevideo/stub/build.gradle.kts
Normal file
17
extensions/primevideo/stub/build.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.revanced.extension"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
}
|
||||||
1
extensions/primevideo/stub/src/main/AndroidManifest.xml
Normal file
1
extensions/primevideo/stub/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest/>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.amazon.avod.fsm;
|
||||||
|
|
||||||
|
public final class SimpleTrigger<T> implements Trigger<T> {
|
||||||
|
public SimpleTrigger(T triggerType) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.amazon.avod.fsm;
|
||||||
|
|
||||||
|
public abstract class StateBase<S, T> {
|
||||||
|
// This method orginally has protected access (modified in patch code).
|
||||||
|
public void doTrigger(Trigger<T> trigger) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.amazon.avod.fsm;
|
||||||
|
|
||||||
|
public interface Trigger<T> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.amazon.avod.media;
|
||||||
|
|
||||||
|
public final class TimeSpan {
|
||||||
|
public long getTotalMilliseconds() {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.amazon.avod.media.ads;
|
||||||
|
|
||||||
|
import com.amazon.avod.media.TimeSpan;
|
||||||
|
|
||||||
|
public interface AdBreak {
|
||||||
|
TimeSpan getDurationExcludingAux();
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.amazon.avod.media.ads.internal.state;
|
||||||
|
|
||||||
|
public abstract class AdBreakState extends AdEnabledPlaybackState {
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.amazon.avod.media.ads.internal.state;
|
||||||
|
|
||||||
|
import com.amazon.avod.media.ads.AdBreak;
|
||||||
|
import com.amazon.avod.media.TimeSpan;
|
||||||
|
|
||||||
|
public class AdBreakTrigger {
|
||||||
|
public AdBreak getBreak() {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan getSeekTarget() {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan getSeekStartPosition() {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.amazon.avod.media.ads.internal.state;
|
||||||
|
|
||||||
|
import com.amazon.avod.fsm.StateBase;
|
||||||
|
import com.amazon.avod.media.playback.state.PlayerStateType;
|
||||||
|
import com.amazon.avod.media.playback.state.trigger.PlayerTriggerType;
|
||||||
|
|
||||||
|
public class AdEnabledPlaybackState extends StateBase<PlayerStateType, PlayerTriggerType> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.amazon.avod.media.ads.internal.state;
|
||||||
|
|
||||||
|
public enum AdEnabledPlayerTriggerType {
|
||||||
|
NO_MORE_ADS_SKIP_TRANSITION
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.amazon.avod.media.ads.internal.state;
|
||||||
|
|
||||||
|
public class ServerInsertedAdBreakState extends AdBreakState {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.amazon.avod.media.playback;
|
||||||
|
|
||||||
|
public interface VideoPlayer {
|
||||||
|
long getCurrentPosition();
|
||||||
|
|
||||||
|
void seekTo(long positionMs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.amazon.avod.media.playback.state;
|
||||||
|
|
||||||
|
public interface PlayerStateType {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.amazon.avod.media.playback.state.trigger;
|
||||||
|
|
||||||
|
public interface PlayerTriggerType {
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id(libs.plugins.android.library.get().pluginId)
|
alias(libs.plugins.android.library)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "app.revanced.extension"
|
namespace = "app.revanced.extension"
|
||||||
compileSdk = 33
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.library")
|
alias(libs.plugins.android.library)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package app.revanced.extension.shared;
|
package app.revanced.extension.shared;
|
||||||
|
|
||||||
import static app.revanced.extension.shared.StringRef.str;
|
import static app.revanced.extension.shared.StringRef.str;
|
||||||
|
import static app.revanced.extension.shared.requests.Route.Method.GET;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.app.AlertDialog;
|
import android.app.Dialog;
|
||||||
import android.app.SearchManager;
|
import android.app.SearchManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
@@ -14,15 +15,20 @@ import android.net.Uri;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.PowerManager;
|
import android.os.PowerManager;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
|
import android.util.Pair;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
import java.net.MalformedURLException;
|
import java.net.MalformedURLException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
/**
|
import app.revanced.extension.shared.requests.Requester;
|
||||||
* @noinspection unused
|
import app.revanced.extension.shared.requests.Route;
|
||||||
*/
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
public class GmsCoreSupport {
|
public class GmsCoreSupport {
|
||||||
private static final String PACKAGE_NAME_YOUTUBE = "com.google.android.youtube";
|
private static final String PACKAGE_NAME_YOUTUBE = "com.google.android.youtube";
|
||||||
private static final String PACKAGE_NAME_YOUTUBE_MUSIC = "com.google.android.apps.youtube.music";
|
private static final String PACKAGE_NAME_YOUTUBE_MUSIC = "com.google.android.apps.youtube.music";
|
||||||
@@ -31,10 +37,24 @@ public class GmsCoreSupport {
|
|||||||
= getGmsCoreVendorGroupId() + ".android.gms";
|
= getGmsCoreVendorGroupId() + ".android.gms";
|
||||||
private static final Uri GMS_CORE_PROVIDER
|
private static final Uri GMS_CORE_PROVIDER
|
||||||
= Uri.parse("content://" + getGmsCoreVendorGroupId() + ".android.gsf.gservices/prefix");
|
= Uri.parse("content://" + getGmsCoreVendorGroupId() + ".android.gsf.gservices/prefix");
|
||||||
private static final String DONT_KILL_MY_APP_LINK
|
private static final String DONT_KILL_MY_APP_URL
|
||||||
= "https://dontkillmyapp.com";
|
= "https://dontkillmyapp.com/";
|
||||||
|
private static final Route DONT_KILL_MY_APP_MANUFACTURER_API
|
||||||
|
= new Route(GET, "/api/v2/{manufacturer}.json");
|
||||||
|
private static final String DONT_KILL_MY_APP_NAME_PARAMETER
|
||||||
|
= "?app=MicroG";
|
||||||
|
private static final String BUILD_MANUFACTURER
|
||||||
|
= Build.MANUFACTURER.toLowerCase(Locale.ROOT).replace(" ", "-");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a manufacturer specific page exists on DontKillMyApp.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private static volatile Boolean DONT_KILL_MY_APP_MANUFACTURER_SUPPORTED;
|
||||||
|
|
||||||
private static void open(String queryOrLink) {
|
private static void open(String queryOrLink) {
|
||||||
|
Logger.printInfo(() -> "Opening link: " + queryOrLink);
|
||||||
|
|
||||||
Intent intent;
|
Intent intent;
|
||||||
try {
|
try {
|
||||||
// Check if queryOrLink is a valid URL.
|
// Check if queryOrLink is a valid URL.
|
||||||
@@ -59,13 +79,27 @@ public class GmsCoreSupport {
|
|||||||
// Use a delay to allow the activity to finish initializing.
|
// Use a delay to allow the activity to finish initializing.
|
||||||
// Otherwise, if device is in dark mode the dialog is shown with wrong color scheme.
|
// Otherwise, if device is in dark mode the dialog is shown with wrong color scheme.
|
||||||
Utils.runOnMainThreadDelayed(() -> {
|
Utils.runOnMainThreadDelayed(() -> {
|
||||||
|
// Create the custom dialog.
|
||||||
|
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
|
||||||
|
context,
|
||||||
|
str("gms_core_dialog_title"), // Title.
|
||||||
|
str(dialogMessageRef), // Message.
|
||||||
|
null, // No EditText.
|
||||||
|
str(positiveButtonTextRef), // OK button text.
|
||||||
|
() -> onPositiveClickListener.onClick(null, 0), // Convert DialogInterface.OnClickListener to Runnable.
|
||||||
|
null, // No Cancel button action.
|
||||||
|
null, // No Neutral button text.
|
||||||
|
null, // No Neutral button action.
|
||||||
|
true // Dismiss dialog when onNeutralClick.
|
||||||
|
);
|
||||||
|
|
||||||
|
Dialog dialog = dialogPair.first;
|
||||||
|
|
||||||
// Do not set cancelable to false, to allow using back button to skip the action,
|
// Do not set cancelable to false, to allow using back button to skip the action,
|
||||||
// just in case the battery change can never be satisfied.
|
// just in case the battery change can never be satisfied.
|
||||||
var dialog = new AlertDialog.Builder(context)
|
dialog.setCancelable(true);
|
||||||
.setTitle(str("gms_core_dialog_title"))
|
|
||||||
.setMessage(str(dialogMessageRef))
|
// Show the dialog
|
||||||
.setPositiveButton(str(positiveButtonTextRef), onPositiveClickListener)
|
|
||||||
.create();
|
|
||||||
Utils.showDialog(context, dialog);
|
Utils.showDialog(context, dialog);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
@@ -73,7 +107,6 @@ public class GmsCoreSupport {
|
|||||||
/**
|
/**
|
||||||
* Injection point.
|
* Injection point.
|
||||||
*/
|
*/
|
||||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
|
||||||
public static void checkGmsCore(Activity context) {
|
public static void checkGmsCore(Activity context) {
|
||||||
try {
|
try {
|
||||||
// Verify the user has not included GmsCore for a root installation.
|
// Verify the user has not included GmsCore for a root installation.
|
||||||
@@ -88,7 +121,7 @@ public class GmsCoreSupport {
|
|||||||
|
|
||||||
// Do not exit. If the app exits before launch completes (and without
|
// Do not exit. If the app exits before launch completes (and without
|
||||||
// opening another activity), then on some devices such as Pixel phone Android 10
|
// opening another activity), then on some devices such as Pixel phone Android 10
|
||||||
// no toast will be shown and the app will continually be relaunched
|
// no toast will be shown and the app will continually relaunch
|
||||||
// with the appearance of a hung app.
|
// with the appearance of a hung app.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +139,11 @@ public class GmsCoreSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if GmsCore is whitelisted from battery optimizations.
|
// Check if GmsCore is whitelisted from battery optimizations.
|
||||||
if (batteryOptimizationsEnabled(context)) {
|
if (isAndroidAutomotive(context)) {
|
||||||
|
// Ignore Android Automotive devices (Google built-in),
|
||||||
|
// as there is no way to disable battery optimizations.
|
||||||
|
Logger.printDebug(() -> "Device is Android Automotive");
|
||||||
|
} else if (batteryOptimizationsEnabled(context)) {
|
||||||
Logger.printInfo(() -> "GmsCore is not whitelisted from battery optimizations");
|
Logger.printInfo(() -> "GmsCore is not whitelisted from battery optimizations");
|
||||||
|
|
||||||
showBatteryOptimizationDialog(context,
|
showBatteryOptimizationDialog(context,
|
||||||
@@ -117,15 +154,20 @@ public class GmsCoreSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if GmsCore is currently running in the background.
|
// Check if GmsCore is currently running in the background.
|
||||||
try (var client = context.getContentResolver().acquireContentProviderClient(GMS_CORE_PROVIDER)) {
|
var client = context.getContentResolver().acquireContentProviderClient(GMS_CORE_PROVIDER);
|
||||||
|
//noinspection TryFinallyCanBeTryWithResources
|
||||||
|
try {
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
Logger.printInfo(() -> "GmsCore is not running in the background");
|
Logger.printInfo(() -> "GmsCore is not running in the background");
|
||||||
|
checkIfDontKillMyAppSupportsManufacturer();
|
||||||
|
|
||||||
showBatteryOptimizationDialog(context,
|
showBatteryOptimizationDialog(context,
|
||||||
"gms_core_dialog_not_whitelisted_not_allowed_in_background_message",
|
"gms_core_dialog_not_whitelisted_not_allowed_in_background_message",
|
||||||
"gms_core_dialog_open_website_text",
|
"gms_core_dialog_open_website_text",
|
||||||
(dialog, id) -> open(DONT_KILL_MY_APP_LINK));
|
(dialog, id) -> openDontKillMyApp());
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (client != null) client.close();
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "checkGmsCore failure", ex);
|
Logger.printException(() -> "checkGmsCore failure", ex);
|
||||||
@@ -139,14 +181,65 @@ public class GmsCoreSupport {
|
|||||||
activity.startActivityForResult(intent, 0);
|
activity.startActivityForResult(intent, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void checkIfDontKillMyAppSupportsManufacturer() {
|
||||||
|
Utils.runOnBackgroundThread(() -> {
|
||||||
|
try {
|
||||||
|
final long start = System.currentTimeMillis();
|
||||||
|
HttpURLConnection connection = Requester.getConnectionFromRoute(
|
||||||
|
DONT_KILL_MY_APP_URL, DONT_KILL_MY_APP_MANUFACTURER_API, BUILD_MANUFACTURER);
|
||||||
|
connection.setConnectTimeout(5000);
|
||||||
|
connection.setReadTimeout(5000);
|
||||||
|
|
||||||
|
final boolean supported = connection.getResponseCode() == 200;
|
||||||
|
Logger.printInfo(() -> "Manufacturer is " + (supported ? "" : "NOT ")
|
||||||
|
+ "listed on DontKillMyApp: " + BUILD_MANUFACTURER
|
||||||
|
+ " fetch took: " + (System.currentTimeMillis() - start) + "ms");
|
||||||
|
DONT_KILL_MY_APP_MANUFACTURER_SUPPORTED = supported;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printInfo(() -> "Could not check if manufacturer is listed on DontKillMyApp: "
|
||||||
|
+ BUILD_MANUFACTURER, ex);
|
||||||
|
DONT_KILL_MY_APP_MANUFACTURER_SUPPORTED = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void openDontKillMyApp() {
|
||||||
|
final Boolean manufacturerSupported = DONT_KILL_MY_APP_MANUFACTURER_SUPPORTED;
|
||||||
|
|
||||||
|
String manufacturerPageToOpen;
|
||||||
|
if (manufacturerSupported == null) {
|
||||||
|
// Fetch has not completed yet. Only happens on extremely slow internet connections
|
||||||
|
// and the user spends less than 1 second reading what's on screen.
|
||||||
|
// Instead of waiting for the fetch (which may timeout),
|
||||||
|
// open the website without a vendor.
|
||||||
|
manufacturerPageToOpen = "";
|
||||||
|
} else if (manufacturerSupported) {
|
||||||
|
manufacturerPageToOpen = BUILD_MANUFACTURER;
|
||||||
|
} else {
|
||||||
|
// No manufacturer specific page exists. Open the general page.
|
||||||
|
manufacturerPageToOpen = "general";
|
||||||
|
}
|
||||||
|
|
||||||
|
open(DONT_KILL_MY_APP_URL + manufacturerPageToOpen + DONT_KILL_MY_APP_NAME_PARAMETER);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return If GmsCore is not whitelisted from battery optimizations.
|
* @return If GmsCore is not whitelisted from battery optimizations.
|
||||||
*/
|
*/
|
||||||
private static boolean batteryOptimizationsEnabled(Context context) {
|
private static boolean batteryOptimizationsEnabled(Context context) {
|
||||||
|
//noinspection ObsoleteSdkInt
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||||
|
// Android 5.0 does not have battery optimization settings.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
var powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
|
var powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
|
||||||
return !powerManager.isIgnoringBatteryOptimizations(GMS_CORE_PACKAGE_NAME);
|
return !powerManager.isIgnoringBatteryOptimizations(GMS_CORE_PACKAGE_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isAndroidAutomotive(Context context) {
|
||||||
|
return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
private static String getGmsCoreDownload() {
|
private static String getGmsCoreDownload() {
|
||||||
final var vendorGroupId = getGmsCoreVendorGroupId();
|
final var vendorGroupId = getGmsCoreVendorGroupId();
|
||||||
//noinspection SwitchStatementWithTooFewBranches
|
//noinspection SwitchStatementWithTooFewBranches
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
package app.revanced.extension.shared;
|
package app.revanced.extension.shared;
|
||||||
|
|
||||||
|
import static app.revanced.extension.shared.settings.BaseSettings.DEBUG;
|
||||||
|
import static app.revanced.extension.shared.settings.BaseSettings.DEBUG_STACKTRACE;
|
||||||
|
import static app.revanced.extension.shared.settings.BaseSettings.DEBUG_TOAST_ON_ERROR;
|
||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import app.revanced.extension.shared.settings.BaseSettings;
|
|
||||||
|
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
import java.io.StringWriter;
|
import java.io.StringWriter;
|
||||||
|
|
||||||
import static app.revanced.extension.shared.settings.BaseSettings.*;
|
import app.revanced.extension.shared.settings.BaseSettings;
|
||||||
|
import app.revanced.extension.shared.settings.preference.LogBufferManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReVanced specific logger. Logging is done to standard device log (accessible thru ADB),
|
||||||
|
* and additionally accessible thru {@link LogBufferManager}.
|
||||||
|
*
|
||||||
|
* All methods are thread safe, and are safe to call even
|
||||||
|
* if {@link Utils#getContext()} is not available.
|
||||||
|
*/
|
||||||
public class Logger {
|
public class Logger {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,99 +29,173 @@ public class Logger {
|
|||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface LogMessage {
|
public interface LogMessage {
|
||||||
|
/**
|
||||||
|
* @return Logger string message. This method is only called if logging is enabled.
|
||||||
|
*/
|
||||||
@NonNull
|
@NonNull
|
||||||
String buildMessageString();
|
String buildMessageString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private enum LogLevel {
|
||||||
* @return For outer classes, this returns {@link Class#getSimpleName()}.
|
DEBUG,
|
||||||
* For static, inner, or anonymous classes, this returns the simple name of the enclosing class.
|
INFO,
|
||||||
* <br>
|
ERROR
|
||||||
* For example, each of these classes return 'SomethingView':
|
}
|
||||||
* <code>
|
|
||||||
* com.company.SomethingView
|
|
||||||
* com.company.SomethingView$StaticClass
|
|
||||||
* com.company.SomethingView$1
|
|
||||||
* </code>
|
|
||||||
*/
|
|
||||||
private String findOuterClassSimpleName() {
|
|
||||||
var selfClass = this.getClass();
|
|
||||||
|
|
||||||
String fullClassName = selfClass.getName();
|
/**
|
||||||
final int dollarSignIndex = fullClassName.indexOf('$');
|
* Log tag prefix. Only used for system logging.
|
||||||
if (dollarSignIndex < 0) {
|
*/
|
||||||
return selfClass.getSimpleName(); // Already an outer class.
|
private static final String REVANCED_LOG_TAG_PREFIX = "revanced: ";
|
||||||
|
|
||||||
|
private static final String LOGGER_CLASS_NAME = Logger.class.getName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return For outer classes, this returns {@link Class#getSimpleName()}.
|
||||||
|
* For static, inner, or anonymous classes, this returns the simple name of the enclosing class.
|
||||||
|
* <br>
|
||||||
|
* For example, each of these classes returns 'SomethingView':
|
||||||
|
* <code>
|
||||||
|
* com.company.SomethingView
|
||||||
|
* com.company.SomethingView$StaticClass
|
||||||
|
* com.company.SomethingView$1
|
||||||
|
* </code>
|
||||||
|
*/
|
||||||
|
private static String getOuterClassSimpleName(Object obj) {
|
||||||
|
Class<?> logClass = obj.getClass();
|
||||||
|
String fullClassName = logClass.getName();
|
||||||
|
final int dollarSignIndex = fullClassName.indexOf('$');
|
||||||
|
if (dollarSignIndex < 0) {
|
||||||
|
return logClass.getSimpleName(); // Already an outer class.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class is inner, static, or anonymous.
|
||||||
|
// Parse the simple name full name.
|
||||||
|
// A class with no package returns index of -1, but incrementing gives index zero which is correct.
|
||||||
|
final int simpleClassNameStartIndex = fullClassName.lastIndexOf('.') + 1;
|
||||||
|
return fullClassName.substring(simpleClassNameStartIndex, dollarSignIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to handle logging to Android Log and {@link LogBufferManager}.
|
||||||
|
* Appends the log message, stack trace (if enabled), and exception (if present) to logBuffer
|
||||||
|
* with class name but without 'revanced:' prefix.
|
||||||
|
*
|
||||||
|
* @param logLevel The log level.
|
||||||
|
* @param message Log message object.
|
||||||
|
* @param ex Optional exception.
|
||||||
|
* @param includeStackTrace If the current stack should be included.
|
||||||
|
* @param showToast If a toast is to be shown.
|
||||||
|
*/
|
||||||
|
private static void logInternal(LogLevel logLevel, LogMessage message, @Nullable Throwable ex,
|
||||||
|
boolean includeStackTrace, boolean showToast) {
|
||||||
|
// It's very important that no Settings are used in this method,
|
||||||
|
// as this code is used when a context is not set and thus referencing
|
||||||
|
// a setting will crash the app.
|
||||||
|
String messageString = message.buildMessageString();
|
||||||
|
String className = getOuterClassSimpleName(message);
|
||||||
|
|
||||||
|
String logText = messageString;
|
||||||
|
|
||||||
|
// Append exception message if present.
|
||||||
|
if (ex != null) {
|
||||||
|
var exceptionMessage = ex.getMessage();
|
||||||
|
if (exceptionMessage != null) {
|
||||||
|
logText += "\nException: " + exceptionMessage;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Class is inner, static, or anonymous.
|
if (includeStackTrace) {
|
||||||
// Parse the simple name full name.
|
var sw = new StringWriter();
|
||||||
// A class with no package returns index of -1, but incrementing gives index zero which is correct.
|
new Throwable().printStackTrace(new PrintWriter(sw));
|
||||||
final int simpleClassNameStartIndex = fullClassName.lastIndexOf('.') + 1;
|
String stackTrace = sw.toString();
|
||||||
return fullClassName.substring(simpleClassNameStartIndex, dollarSignIndex);
|
// Remove the stacktrace elements of this class.
|
||||||
|
final int loggerIndex = stackTrace.lastIndexOf(LOGGER_CLASS_NAME);
|
||||||
|
final int loggerBegins = stackTrace.indexOf('\n', loggerIndex);
|
||||||
|
logText += stackTrace.substring(loggerBegins);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not include "revanced:" prefix in clipboard logs.
|
||||||
|
String managerToastString = className + ": " + logText;
|
||||||
|
LogBufferManager.appendToLogBuffer(managerToastString);
|
||||||
|
|
||||||
|
String logTag = REVANCED_LOG_TAG_PREFIX + className;
|
||||||
|
switch (logLevel) {
|
||||||
|
case DEBUG:
|
||||||
|
if (ex == null) Log.d(logTag, logText);
|
||||||
|
else Log.d(logTag, logText, ex);
|
||||||
|
break;
|
||||||
|
case INFO:
|
||||||
|
if (ex == null) Log.i(logTag, logText);
|
||||||
|
else Log.i(logTag, logText, ex);
|
||||||
|
break;
|
||||||
|
case ERROR:
|
||||||
|
if (ex == null) Log.e(logTag, logText);
|
||||||
|
else Log.e(logTag, logText, ex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
Utils.showToastLong(managerToastString);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String REVANCED_LOG_PREFIX = "revanced: ";
|
private static boolean shouldLogDebug() {
|
||||||
|
// If the app is still starting up and the context is not yet set,
|
||||||
|
// then allow debug logging regardless what the debug setting actually is.
|
||||||
|
return Utils.context == null || DEBUG.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean shouldShowErrorToast() {
|
||||||
|
return Utils.context != null && DEBUG_TOAST_ON_ERROR.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean includeStackTrace() {
|
||||||
|
return Utils.context != null && DEBUG_STACKTRACE.get();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs debug messages under the outer class name of the code calling this method.
|
* Logs debug messages under the outer class name of the code calling this method.
|
||||||
* Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()}
|
* <p>
|
||||||
* so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
|
* Whenever possible, the log string should be constructed entirely inside
|
||||||
|
* {@link LogMessage#buildMessageString()} so the performance cost of
|
||||||
|
* building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
|
||||||
*/
|
*/
|
||||||
public static void printDebug(@NonNull LogMessage message) {
|
public static void printDebug(LogMessage message) {
|
||||||
printDebug(message, null);
|
printDebug(message, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs debug messages under the outer class name of the code calling this method.
|
* Logs debug messages under the outer class name of the code calling this method.
|
||||||
* Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()}
|
* <p>
|
||||||
* so the performance cost of building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
|
* Whenever possible, the log string should be constructed entirely inside
|
||||||
|
* {@link LogMessage#buildMessageString()} so the performance cost of
|
||||||
|
* building strings is paid only if {@link BaseSettings#DEBUG} is enabled.
|
||||||
*/
|
*/
|
||||||
public static void printDebug(@NonNull LogMessage message, @Nullable Exception ex) {
|
public static void printDebug(LogMessage message, @Nullable Exception ex) {
|
||||||
if (DEBUG.get()) {
|
if (shouldLogDebug()) {
|
||||||
String logMessage = message.buildMessageString();
|
logInternal(LogLevel.DEBUG, message, ex, includeStackTrace(), false);
|
||||||
String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
|
|
||||||
|
|
||||||
if (DEBUG_STACKTRACE.get()) {
|
|
||||||
var builder = new StringBuilder(logMessage);
|
|
||||||
var sw = new StringWriter();
|
|
||||||
new Throwable().printStackTrace(new PrintWriter(sw));
|
|
||||||
|
|
||||||
builder.append('\n').append(sw);
|
|
||||||
logMessage = builder.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ex == null) {
|
|
||||||
Log.d(logTag, logMessage);
|
|
||||||
} else {
|
|
||||||
Log.d(logTag, logMessage, ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs information messages using the outer class name of the code calling this method.
|
* Logs information messages using the outer class name of the code calling this method.
|
||||||
*/
|
*/
|
||||||
public static void printInfo(@NonNull LogMessage message) {
|
public static void printInfo(LogMessage message) {
|
||||||
printInfo(message, null);
|
printInfo(message, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs information messages using the outer class name of the code calling this method.
|
* Logs information messages using the outer class name of the code calling this method.
|
||||||
*/
|
*/
|
||||||
public static void printInfo(@NonNull LogMessage message, @Nullable Exception ex) {
|
public static void printInfo(LogMessage message, @Nullable Exception ex) {
|
||||||
String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
|
logInternal(LogLevel.INFO, message, ex, includeStackTrace(), false);
|
||||||
String logMessage = message.buildMessageString();
|
|
||||||
if (ex == null) {
|
|
||||||
Log.i(logTag, logMessage);
|
|
||||||
} else {
|
|
||||||
Log.i(logTag, logMessage, ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs exceptions under the outer class name of the code calling this method.
|
* Logs exceptions under the outer class name of the code calling this method.
|
||||||
|
* Appends the log message, exception (if present), and toast message (if enabled) to logBuffer.
|
||||||
*/
|
*/
|
||||||
public static void printException(@NonNull LogMessage message) {
|
public static void printException(LogMessage message) {
|
||||||
printException(message, null);
|
printException(message, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,35 +208,7 @@ public class Logger {
|
|||||||
* @param message log message
|
* @param message log message
|
||||||
* @param ex exception (optional)
|
* @param ex exception (optional)
|
||||||
*/
|
*/
|
||||||
public static void printException(@NonNull LogMessage message, @Nullable Throwable ex) {
|
public static void printException(LogMessage message, @Nullable Throwable ex) {
|
||||||
String messageString = message.buildMessageString();
|
logInternal(LogLevel.ERROR, message, ex, includeStackTrace(), shouldShowErrorToast());
|
||||||
String outerClassSimpleName = message.findOuterClassSimpleName();
|
|
||||||
String logMessage = REVANCED_LOG_PREFIX + outerClassSimpleName;
|
|
||||||
if (ex == null) {
|
|
||||||
Log.e(logMessage, messageString);
|
|
||||||
} else {
|
|
||||||
Log.e(logMessage, messageString, ex);
|
|
||||||
}
|
|
||||||
if (DEBUG_TOAST_ON_ERROR.get()) {
|
|
||||||
Utils.showToastLong(outerClassSimpleName + ": " + messageString);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Logging to use if {@link BaseSettings#DEBUG} or {@link Utils#getContext()} may not be initialized.
|
|
||||||
* Normally this method should not be used.
|
|
||||||
*/
|
|
||||||
public static void initializationInfo(@NonNull Class<?> callingClass, @NonNull String message) {
|
|
||||||
Log.i(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logging to use if {@link BaseSettings#DEBUG} or {@link Utils#getContext()} may not be initialized.
|
|
||||||
* Normally this method should not be used.
|
|
||||||
*/
|
|
||||||
public static void initializationException(@NonNull Class<?> callingClass, @NonNull String message,
|
|
||||||
@Nullable Exception ex) {
|
|
||||||
Log.e(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message, ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,21 @@ package app.revanced.extension.shared.checks;
|
|||||||
import static android.text.Html.FROM_HTML_MODE_COMPACT;
|
import static android.text.Html.FROM_HTML_MODE_COMPACT;
|
||||||
import static app.revanced.extension.shared.StringRef.str;
|
import static app.revanced.extension.shared.StringRef.str;
|
||||||
import static app.revanced.extension.shared.Utils.DialogFragmentOnStartAction;
|
import static app.revanced.extension.shared.Utils.DialogFragmentOnStartAction;
|
||||||
|
import static app.revanced.extension.shared.Utils.dipToPixels;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.app.AlertDialog;
|
import android.app.Dialog;
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.graphics.PorterDuff;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.text.Html;
|
import android.text.Html;
|
||||||
|
import android.util.Pair;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.View;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
@@ -86,38 +92,58 @@ abstract class Check {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Utils.runOnMainThreadDelayed(() -> {
|
Utils.runOnMainThreadDelayed(() -> {
|
||||||
AlertDialog alert = new AlertDialog.Builder(activity)
|
// Create the custom dialog.
|
||||||
.setCancelable(false)
|
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
|
||||||
.setIconAttribute(android.R.attr.alertDialogIcon)
|
activity,
|
||||||
.setTitle(str("revanced_check_environment_failed_title"))
|
str("revanced_check_environment_failed_title"), // Title.
|
||||||
.setMessage(message)
|
message, // Message.
|
||||||
.setPositiveButton(
|
null, // No EditText.
|
||||||
" ",
|
str("revanced_check_environment_dialog_open_official_source_button"), // OK button text.
|
||||||
(dialog, which) -> {
|
() -> {
|
||||||
final var intent = new Intent(Intent.ACTION_VIEW, GOOD_SOURCE);
|
// Action for the OK (website) button.
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
final var intent = new Intent(Intent.ACTION_VIEW, GOOD_SOURCE);
|
||||||
activity.startActivity(intent);
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
activity.startActivity(intent);
|
||||||
|
|
||||||
// Shutdown to prevent the user from navigating back to this app,
|
// Shutdown to prevent the user from navigating back to this app,
|
||||||
// which is no longer showing a warning dialog.
|
// which is no longer showing a warning dialog.
|
||||||
activity.finishAffinity();
|
activity.finishAffinity();
|
||||||
System.exit(0);
|
System.exit(0);
|
||||||
}
|
},
|
||||||
).setNegativeButton(
|
null, // No cancel button.
|
||||||
" ",
|
str("revanced_check_environment_dialog_ignore_button"), // Neutral button text.
|
||||||
(dialog, which) -> {
|
() -> {
|
||||||
// Cleanup data if the user incorrectly imported a huge negative number.
|
// Neutral button action.
|
||||||
final int current = Math.max(0, BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get());
|
// Cleanup data if the user incorrectly imported a huge negative number.
|
||||||
BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(current + 1);
|
final int current = Math.max(0, BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.get());
|
||||||
|
BaseSettings.CHECK_ENVIRONMENT_WARNINGS_ISSUED.save(current + 1);
|
||||||
|
},
|
||||||
|
true // Dismiss dialog when onNeutralClick.
|
||||||
|
);
|
||||||
|
|
||||||
dialog.dismiss();
|
// Get the dialog and main layout.
|
||||||
}
|
Dialog dialog = dialogPair.first;
|
||||||
).create();
|
LinearLayout mainLayout = dialogPair.second;
|
||||||
|
|
||||||
Utils.showDialog(activity, alert, false, new DialogFragmentOnStartAction() {
|
// Add icon to the dialog.
|
||||||
|
ImageView iconView = new ImageView(activity);
|
||||||
|
iconView.setImageResource(Utils.getResourceIdentifier("revanced_ic_dialog_alert", "drawable"));
|
||||||
|
iconView.setColorFilter(Utils.getAppForegroundColor(), PorterDuff.Mode.SRC_IN);
|
||||||
|
iconView.setPadding(0, 0, 0, 0);
|
||||||
|
LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
);
|
||||||
|
iconParams.gravity = Gravity.CENTER;
|
||||||
|
mainLayout.addView(iconView, 0); // Add icon at the top.
|
||||||
|
|
||||||
|
dialog.setCancelable(false);
|
||||||
|
|
||||||
|
// Show the dialog.
|
||||||
|
Utils.showDialog(activity, dialog, false, new DialogFragmentOnStartAction() {
|
||||||
boolean hasRun;
|
boolean hasRun;
|
||||||
@Override
|
@Override
|
||||||
public void onStart(AlertDialog dialog) {
|
public void onStart(Dialog dialog) {
|
||||||
// Only run this once, otherwise if the user changes to a different app
|
// Only run this once, otherwise if the user changes to a different app
|
||||||
// then changes back, this handler will run again and disable the buttons.
|
// then changes back, this handler will run again and disable the buttons.
|
||||||
if (hasRun) {
|
if (hasRun) {
|
||||||
@@ -125,19 +151,43 @@ abstract class Check {
|
|||||||
}
|
}
|
||||||
hasRun = true;
|
hasRun = true;
|
||||||
|
|
||||||
var openWebsiteButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
|
// Get the button container to access buttons.
|
||||||
|
LinearLayout buttonContainer = (LinearLayout) mainLayout.getChildAt(mainLayout.getChildCount() - 1);
|
||||||
|
|
||||||
|
Button openWebsiteButton;
|
||||||
|
Button ignoreButton;
|
||||||
|
|
||||||
|
// Check if buttons are in a single-row layout (buttonContainer has one child: rowContainer).
|
||||||
|
if (buttonContainer.getChildCount() == 1 && buttonContainer.getChildAt(0) instanceof LinearLayout) {
|
||||||
|
LinearLayout rowContainer = (LinearLayout) buttonContainer.getChildAt(0);
|
||||||
|
// Neutral button is the first child (index 0).
|
||||||
|
ignoreButton = (Button) rowContainer.getChildAt(0);
|
||||||
|
// OK button is the last child.
|
||||||
|
openWebsiteButton = (Button) rowContainer.getChildAt(rowContainer.getChildCount() - 1);
|
||||||
|
} else {
|
||||||
|
// Multi-row layout: buttons are in separate containers, ordered OK, Cancel, Neutral.
|
||||||
|
LinearLayout okContainer =
|
||||||
|
(LinearLayout) buttonContainer.getChildAt(0); // OK is first.
|
||||||
|
openWebsiteButton = (Button) okContainer.getChildAt(0);
|
||||||
|
LinearLayout neutralContainer =
|
||||||
|
(LinearLayout)buttonContainer.getChildAt(buttonContainer.getChildCount() - 1); // Neutral is last.
|
||||||
|
ignoreButton = (Button) neutralContainer.getChildAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initially set buttons to INVISIBLE and disabled.
|
||||||
|
openWebsiteButton.setVisibility(View.INVISIBLE);
|
||||||
openWebsiteButton.setEnabled(false);
|
openWebsiteButton.setEnabled(false);
|
||||||
|
ignoreButton.setVisibility(View.INVISIBLE);
|
||||||
|
ignoreButton.setEnabled(false);
|
||||||
|
|
||||||
var dismissButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE);
|
// Start the countdown for showing and enabling buttons.
|
||||||
dismissButton.setEnabled(false);
|
getCountdownRunnable(ignoreButton, openWebsiteButton).run();
|
||||||
|
|
||||||
getCountdownRunnable(dismissButton, openWebsiteButton).run();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 1000); // Use a delay, so this dialog is shown on top of any other startup dialogs.
|
}, 1000); // Use a delay, so this dialog is shown on top of any other startup dialogs.
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Runnable getCountdownRunnable(Button dismissButton, Button openWebsiteButton) {
|
private static Runnable getCountdownRunnable(Button ignoreButton, Button openWebsiteButton) {
|
||||||
return new Runnable() {
|
return new Runnable() {
|
||||||
private int secondsRemaining = SECONDS_BEFORE_SHOWING_IGNORE_BUTTON;
|
private int secondsRemaining = SECONDS_BEFORE_SHOWING_IGNORE_BUTTON;
|
||||||
|
|
||||||
@@ -146,17 +196,15 @@ abstract class Check {
|
|||||||
Utils.verifyOnMainThread();
|
Utils.verifyOnMainThread();
|
||||||
|
|
||||||
if (secondsRemaining > 0) {
|
if (secondsRemaining > 0) {
|
||||||
if (secondsRemaining - SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON == 0) {
|
if (secondsRemaining - SECONDS_BEFORE_SHOWING_WEBSITE_BUTTON <= 0) {
|
||||||
openWebsiteButton.setText(str("revanced_check_environment_dialog_open_official_source_button"));
|
openWebsiteButton.setVisibility(View.VISIBLE);
|
||||||
openWebsiteButton.setEnabled(true);
|
openWebsiteButton.setEnabled(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
secondsRemaining--;
|
secondsRemaining--;
|
||||||
|
|
||||||
Utils.runOnMainThreadDelayed(this, 1000);
|
Utils.runOnMainThreadDelayed(this, 1000);
|
||||||
} else {
|
} else {
|
||||||
dismissButton.setText(str("revanced_check_environment_dialog_ignore_button"));
|
ignoreButton.setVisibility(View.VISIBLE);
|
||||||
dismissButton.setEnabled(true);
|
ignoreButton.setEnabled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ public class Route {
|
|||||||
|
|
||||||
private int countMatches(CharSequence seq, char c) {
|
private int countMatches(CharSequence seq, char c) {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
for (int i = 0; i < seq.length(); i++) {
|
for (int i = 0, length = seq.length(); i < length; i++) {
|
||||||
if (seq.charAt(i) == c)
|
if (seq.charAt(i) == c)
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package app.revanced.extension.shared.settings;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public enum AppLanguage {
|
||||||
|
/**
|
||||||
|
* The current app language.
|
||||||
|
*/
|
||||||
|
DEFAULT,
|
||||||
|
|
||||||
|
// Languages codes not included with YouTube, but are translated on Crowdin
|
||||||
|
GA,
|
||||||
|
|
||||||
|
// Language codes found in locale_config.xml
|
||||||
|
// All region specific variants have been removed.
|
||||||
|
AF,
|
||||||
|
AM,
|
||||||
|
AR,
|
||||||
|
AS,
|
||||||
|
AZ,
|
||||||
|
BE,
|
||||||
|
BG,
|
||||||
|
BN,
|
||||||
|
BS,
|
||||||
|
CA,
|
||||||
|
CS,
|
||||||
|
DA,
|
||||||
|
DE,
|
||||||
|
EL,
|
||||||
|
EN,
|
||||||
|
ES,
|
||||||
|
ET,
|
||||||
|
EU,
|
||||||
|
FA,
|
||||||
|
FI,
|
||||||
|
FR,
|
||||||
|
GL,
|
||||||
|
GU,
|
||||||
|
HI,
|
||||||
|
HE, // App uses obsolete 'IW' and not the modern 'HE' ISO code.
|
||||||
|
HR,
|
||||||
|
HU,
|
||||||
|
HY,
|
||||||
|
ID,
|
||||||
|
IS,
|
||||||
|
IT,
|
||||||
|
JA,
|
||||||
|
KA,
|
||||||
|
KK,
|
||||||
|
KM,
|
||||||
|
KN,
|
||||||
|
KO,
|
||||||
|
KY,
|
||||||
|
LO,
|
||||||
|
LT,
|
||||||
|
LV,
|
||||||
|
MK,
|
||||||
|
ML,
|
||||||
|
MN,
|
||||||
|
MR,
|
||||||
|
MS,
|
||||||
|
MY,
|
||||||
|
NE,
|
||||||
|
NL,
|
||||||
|
NB,
|
||||||
|
OR,
|
||||||
|
PA,
|
||||||
|
PL,
|
||||||
|
PT,
|
||||||
|
RO,
|
||||||
|
RU,
|
||||||
|
SI,
|
||||||
|
SK,
|
||||||
|
SL,
|
||||||
|
SQ,
|
||||||
|
SR,
|
||||||
|
SV,
|
||||||
|
SW,
|
||||||
|
TA,
|
||||||
|
TE,
|
||||||
|
TH,
|
||||||
|
TL,
|
||||||
|
TR,
|
||||||
|
UK,
|
||||||
|
UR,
|
||||||
|
UZ,
|
||||||
|
VI,
|
||||||
|
ZH,
|
||||||
|
ZU;
|
||||||
|
|
||||||
|
private final String language;
|
||||||
|
private final Locale locale;
|
||||||
|
|
||||||
|
AppLanguage() {
|
||||||
|
language = name().toLowerCase(Locale.US);
|
||||||
|
locale = Locale.forLanguageTag(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The 2 letter ISO 639_1 language code.
|
||||||
|
*/
|
||||||
|
public String getLanguage() {
|
||||||
|
// Changing the app language does not force the app to completely restart,
|
||||||
|
// so the default needs to be the current language and not a static field.
|
||||||
|
if (this == DEFAULT) {
|
||||||
|
return Locale.getDefault().getLanguage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Locale getLocale() {
|
||||||
|
if (this == DEFAULT) {
|
||||||
|
return Locale.getDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@ package app.revanced.extension.shared.settings;
|
|||||||
import static java.lang.Boolean.FALSE;
|
import static java.lang.Boolean.FALSE;
|
||||||
import static java.lang.Boolean.TRUE;
|
import static java.lang.Boolean.TRUE;
|
||||||
import static app.revanced.extension.shared.settings.Setting.parent;
|
import static app.revanced.extension.shared.settings.Setting.parent;
|
||||||
|
import static app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch.AudioStreamLanguageOverrideAvailability;
|
||||||
import static app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch.SpoofiOSAvailability;
|
import static app.revanced.extension.shared.spoof.SpoofVideoStreamsPatch.SpoofiOSAvailability;
|
||||||
|
|
||||||
import app.revanced.extension.shared.spoof.AudioStreamLanguage;
|
|
||||||
import app.revanced.extension.shared.spoof.ClientType;
|
import app.revanced.extension.shared.spoof.ClientType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,10 +21,19 @@ public class BaseSettings {
|
|||||||
|
|
||||||
public static final IntegerSetting CHECK_ENVIRONMENT_WARNINGS_ISSUED = new IntegerSetting("revanced_check_environment_warnings_issued", 0, true, false);
|
public static final IntegerSetting CHECK_ENVIRONMENT_WARNINGS_ISSUED = new IntegerSetting("revanced_check_environment_warnings_issued", 0, true, false);
|
||||||
|
|
||||||
|
public static final EnumSetting<AppLanguage> REVANCED_LANGUAGE = new EnumSetting<>("revanced_language", AppLanguage.DEFAULT, true, "revanced_language_user_dialog_message");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the icons declared in the preferences created during patching. If no icons or styles are declared then this setting does nothing.
|
||||||
|
*/
|
||||||
|
public static final BooleanSetting SHOW_MENU_ICONS = new BooleanSetting("revanced_show_menu_icons", TRUE, true);
|
||||||
|
|
||||||
public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("revanced_spoof_video_streams", TRUE, true, "revanced_spoof_video_streams_user_dialog_message");
|
public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("revanced_spoof_video_streams", TRUE, true, "revanced_spoof_video_streams_user_dialog_message");
|
||||||
public static final EnumSetting<AudioStreamLanguage> SPOOF_VIDEO_STREAMS_LANGUAGE = new EnumSetting<>("revanced_spoof_video_streams_language", AudioStreamLanguage.DEFAULT, new SpoofiOSAvailability());
|
public static final EnumSetting<AppLanguage> SPOOF_VIDEO_STREAMS_LANGUAGE = new EnumSetting<>("revanced_spoof_video_streams_language", AppLanguage.DEFAULT, new AudioStreamLanguageOverrideAvailability());
|
||||||
|
public static final BooleanSetting SPOOF_STREAMING_DATA_STATS_FOR_NERDS = new BooleanSetting("revanced_spoof_streaming_data_stats_for_nerds", TRUE, parent(SPOOF_VIDEO_STREAMS));
|
||||||
public static final BooleanSetting SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_video_streams_ios_force_avc", FALSE, true,
|
public static final BooleanSetting SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_video_streams_ios_force_avc", FALSE, true,
|
||||||
"revanced_spoof_video_streams_ios_force_avc_user_dialog_message", new SpoofiOSAvailability());
|
"revanced_spoof_video_streams_ios_force_avc_user_dialog_message", new SpoofiOSAvailability());
|
||||||
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client", ClientType.ANDROID_VR, true, parent(SPOOF_VIDEO_STREAMS));
|
// Client type must be last spoof setting due to cyclic references.
|
||||||
|
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type", ClientType.ANDROID_UNPLUGGED, true, parent(SPOOF_VIDEO_STREAMS));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ public class BooleanSetting extends Setting<Boolean> {
|
|||||||
*/
|
*/
|
||||||
public static void privateSetValue(@NonNull BooleanSetting setting, @NonNull Boolean newValue) {
|
public static void privateSetValue(@NonNull BooleanSetting setting, @NonNull Boolean newValue) {
|
||||||
setting.value = Objects.requireNonNull(newValue);
|
setting.value = Objects.requireNonNull(newValue);
|
||||||
|
|
||||||
|
if (setting.isSetToDefault()) {
|
||||||
|
setting.removeFromPreferences();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -65,10 +69,8 @@ public class BooleanSetting extends Setting<Boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void save(@NonNull Boolean newValue) {
|
public void saveToPreferences() {
|
||||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
preferences.saveBoolean(key, value);
|
||||||
value = Objects.requireNonNull(newValue);
|
|
||||||
preferences.saveBoolean(key, newValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
|||||||
@@ -71,15 +71,20 @@ public class EnumSetting<T extends Enum<?>> extends Setting<T> {
|
|||||||
json.put(importExportKey, value.name().toLowerCase(Locale.ENGLISH));
|
json.put(importExportKey, value.name().toLowerCase(Locale.ENGLISH));
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
/**
|
||||||
private T getEnumFromString(String enumName) {
|
* @param enumName Enum name. Casing does not matter.
|
||||||
|
* @return Enum of this type with the same declared name.
|
||||||
|
* @throws IllegalArgumentException if the name is not a valid enum of this type.
|
||||||
|
*/
|
||||||
|
protected T getEnumFromString(String enumName) {
|
||||||
//noinspection ConstantConditions
|
//noinspection ConstantConditions
|
||||||
for (Enum<?> value : defaultValue.getClass().getEnumConstants()) {
|
for (Enum<?> value : defaultValue.getClass().getEnumConstants()) {
|
||||||
if (value.name().equalsIgnoreCase(enumName)) {
|
if (value.name().equalsIgnoreCase(enumName)) {
|
||||||
// noinspection unchecked
|
//noinspection unchecked
|
||||||
return (T) value;
|
return (T) value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new IllegalArgumentException("Unknown enum value: " + enumName);
|
throw new IllegalArgumentException("Unknown enum value: " + enumName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +94,8 @@ public class EnumSetting<T extends Enum<?>> extends Setting<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void save(@NonNull T newValue) {
|
public void saveToPreferences() {
|
||||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
preferences.saveEnumAsString(key, value);
|
||||||
value = Objects.requireNonNull(newValue);
|
|
||||||
preferences.saveEnumAsString(key, newValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@@ -105,7 +108,9 @@ public class EnumSetting<T extends Enum<?>> extends Setting<T> {
|
|||||||
* Availability based on if this setting is currently set to any of the provided types.
|
* Availability based on if this setting is currently set to any of the provided types.
|
||||||
*/
|
*/
|
||||||
@SafeVarargs
|
@SafeVarargs
|
||||||
public final Setting.Availability availability(@NonNull T... types) {
|
public final Setting.Availability availability(T... types) {
|
||||||
|
Objects.requireNonNull(types);
|
||||||
|
|
||||||
return () -> {
|
return () -> {
|
||||||
T currentEnumType = get();
|
T currentEnumType = get();
|
||||||
for (T enumType : types) {
|
for (T enumType : types) {
|
||||||
|
|||||||
@@ -55,10 +55,8 @@ public class FloatSetting extends Setting<Float> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void save(@NonNull Float newValue) {
|
public void saveToPreferences() {
|
||||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
preferences.saveFloatString(key, value);
|
||||||
value = Objects.requireNonNull(newValue);
|
|
||||||
preferences.saveFloatString(key, newValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
|||||||
@@ -55,10 +55,8 @@ public class IntegerSetting extends Setting<Integer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void save(@NonNull Integer newValue) {
|
public void saveToPreferences() {
|
||||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
preferences.saveIntegerString(key, value);
|
||||||
value = Objects.requireNonNull(newValue);
|
|
||||||
preferences.saveIntegerString(key, newValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
|||||||
@@ -55,10 +55,8 @@ public class LongSetting extends Setting<Long> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void save(@NonNull Long newValue) {
|
public void saveToPreferences() {
|
||||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
preferences.saveLongString(key, value);
|
||||||
value = Objects.requireNonNull(newValue);
|
|
||||||
preferences.saveLongString(key, newValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import java.util.*;
|
|||||||
|
|
||||||
import static app.revanced.extension.shared.StringRef.str;
|
import static app.revanced.extension.shared.StringRef.str;
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
public abstract class Setting<T> {
|
public abstract class Setting<T> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,16 +28,14 @@ public abstract class Setting<T> {
|
|||||||
/**
|
/**
|
||||||
* Availability based on a single parent setting being enabled.
|
* Availability based on a single parent setting being enabled.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
public static Availability parent(BooleanSetting parent) {
|
||||||
public static Availability parent(@NonNull BooleanSetting parent) {
|
|
||||||
return parent::get;
|
return parent::get;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Availability based on all parents being enabled.
|
* Availability based on all parents being enabled.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
public static Availability parentsAll(BooleanSetting... parents) {
|
||||||
public static Availability parentsAll(@NonNull BooleanSetting... parents) {
|
|
||||||
return () -> {
|
return () -> {
|
||||||
for (BooleanSetting parent : parents) {
|
for (BooleanSetting parent : parents) {
|
||||||
if (!parent.get()) return false;
|
if (!parent.get()) return false;
|
||||||
@@ -50,8 +47,7 @@ public abstract class Setting<T> {
|
|||||||
/**
|
/**
|
||||||
* Availability based on any parent being enabled.
|
* Availability based on any parent being enabled.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
public static Availability parentsAny(BooleanSetting... parents) {
|
||||||
public static Availability parentsAny(@NonNull BooleanSetting... parents) {
|
|
||||||
return () -> {
|
return () -> {
|
||||||
for (BooleanSetting parent : parents) {
|
for (BooleanSetting parent : parents) {
|
||||||
if (parent.get()) return true;
|
if (parent.get()) return true;
|
||||||
@@ -80,7 +76,7 @@ public abstract class Setting<T> {
|
|||||||
/**
|
/**
|
||||||
* Adds a callback for {@link #importFromJSON(Context, String)} and {@link #exportToJson(Context)}.
|
* Adds a callback for {@link #importFromJSON(Context, String)} and {@link #exportToJson(Context)}.
|
||||||
*/
|
*/
|
||||||
public static void addImportExportCallback(@NonNull ImportExportCallback callback) {
|
public static void addImportExportCallback(ImportExportCallback callback) {
|
||||||
importExportCallbacks.add(Objects.requireNonNull(callback));
|
importExportCallbacks.add(Objects.requireNonNull(callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,14 +97,13 @@ public abstract class Setting<T> {
|
|||||||
public static final SharedPrefCategory preferences = new SharedPrefCategory("revanced_prefs");
|
public static final SharedPrefCategory preferences = new SharedPrefCategory("revanced_prefs");
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public static Setting<?> getSettingFromPath(@NonNull String str) {
|
public static Setting<?> getSettingFromPath(String str) {
|
||||||
return PATH_TO_SETTINGS.get(str);
|
return PATH_TO_SETTINGS.get(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return All settings that have been created.
|
* @return All settings that have been created.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
|
||||||
public static List<Setting<?>> allLoadedSettings() {
|
public static List<Setting<?>> allLoadedSettings() {
|
||||||
return Collections.unmodifiableList(SETTINGS);
|
return Collections.unmodifiableList(SETTINGS);
|
||||||
}
|
}
|
||||||
@@ -116,7 +111,6 @@ public abstract class Setting<T> {
|
|||||||
/**
|
/**
|
||||||
* @return All settings that have been created, sorted by keys.
|
* @return All settings that have been created, sorted by keys.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
|
||||||
private static List<Setting<?>> allLoadedSettingsSorted() {
|
private static List<Setting<?>> allLoadedSettingsSorted() {
|
||||||
Collections.sort(SETTINGS, (Setting<?> o1, Setting<?> o2) -> o1.key.compareTo(o2.key));
|
Collections.sort(SETTINGS, (Setting<?> o1, Setting<?> o2) -> o1.key.compareTo(o2.key));
|
||||||
return allLoadedSettings();
|
return allLoadedSettings();
|
||||||
@@ -125,13 +119,11 @@ public abstract class Setting<T> {
|
|||||||
/**
|
/**
|
||||||
* The key used to store the value in the shared preferences.
|
* The key used to store the value in the shared preferences.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
|
||||||
public final String key;
|
public final String key;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default value of the setting.
|
* The default value of the setting.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
|
||||||
public final T defaultValue;
|
public final T defaultValue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,7 +145,6 @@ public abstract class Setting<T> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirmation message to display, if the user tries to change the setting from the default value.
|
* Confirmation message to display, if the user tries to change the setting from the default value.
|
||||||
* Currently this works only for Boolean setting types.
|
|
||||||
*/
|
*/
|
||||||
@Nullable
|
@Nullable
|
||||||
public final StringRef userDialogMessage;
|
public final StringRef userDialogMessage;
|
||||||
@@ -163,7 +154,6 @@ public abstract class Setting<T> {
|
|||||||
/**
|
/**
|
||||||
* The value of the setting.
|
* The value of the setting.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
|
||||||
protected volatile T value;
|
protected volatile T value;
|
||||||
|
|
||||||
public Setting(String key, T defaultValue) {
|
public Setting(String key, T defaultValue) {
|
||||||
@@ -201,8 +191,8 @@ public abstract class Setting<T> {
|
|||||||
* @param userDialogMessage Confirmation message to display, if the user tries to change the setting from the default value.
|
* @param userDialogMessage Confirmation message to display, if the user tries to change the setting from the default value.
|
||||||
* @param availability Condition that must be true, for this setting to be available to configure.
|
* @param availability Condition that must be true, for this setting to be available to configure.
|
||||||
*/
|
*/
|
||||||
public Setting(@NonNull String key,
|
public Setting(String key,
|
||||||
@NonNull T defaultValue,
|
T defaultValue,
|
||||||
boolean rebootApp,
|
boolean rebootApp,
|
||||||
boolean includeWithImportExport,
|
boolean includeWithImportExport,
|
||||||
@Nullable String userDialogMessage,
|
@Nullable String userDialogMessage,
|
||||||
@@ -229,7 +219,7 @@ public abstract class Setting<T> {
|
|||||||
/**
|
/**
|
||||||
* Migrate a setting value if the path is renamed but otherwise the old and new settings are identical.
|
* Migrate a setting value if the path is renamed but otherwise the old and new settings are identical.
|
||||||
*/
|
*/
|
||||||
public static <T> void migrateOldSettingToNew(@NonNull Setting<T> oldSetting, @NonNull Setting<T> newSetting) {
|
public static <T> void migrateOldSettingToNew(Setting<T> oldSetting, Setting<T> newSetting) {
|
||||||
if (oldSetting == newSetting) throw new IllegalArgumentException();
|
if (oldSetting == newSetting) throw new IllegalArgumentException();
|
||||||
|
|
||||||
if (!oldSetting.isSetToDefault()) {
|
if (!oldSetting.isSetToDefault()) {
|
||||||
@@ -244,7 +234,8 @@ public abstract class Setting<T> {
|
|||||||
*
|
*
|
||||||
* This method will be deleted in the future.
|
* This method will be deleted in the future.
|
||||||
*/
|
*/
|
||||||
public static void migrateFromOldPreferences(@NonNull SharedPrefCategory oldPrefs, @NonNull Setting setting, String settingKey) {
|
@SuppressWarnings("rawtypes")
|
||||||
|
public static void migrateFromOldPreferences(SharedPrefCategory oldPrefs, Setting setting, String settingKey) {
|
||||||
if (!oldPrefs.preferences.contains(settingKey)) {
|
if (!oldPrefs.preferences.contains(settingKey)) {
|
||||||
return; // Nothing to do.
|
return; // Nothing to do.
|
||||||
}
|
}
|
||||||
@@ -286,14 +277,21 @@ public abstract class Setting<T> {
|
|||||||
* This intentionally is a static method to deter
|
* This intentionally is a static method to deter
|
||||||
* accidental usage when {@link #save(Object)} was intended.
|
* accidental usage when {@link #save(Object)} was intended.
|
||||||
*/
|
*/
|
||||||
public static void privateSetValueFromString(@NonNull Setting<?> setting, @NonNull String newValue) {
|
public static void privateSetValueFromString(Setting<?> setting, String newValue) {
|
||||||
setting.setValueFromString(newValue);
|
setting.setValueFromString(newValue);
|
||||||
|
|
||||||
|
// Clear the preference value since default is used, to allow changing
|
||||||
|
// the changing the default for a future release. Without this after upgrading
|
||||||
|
// the saved value will be whatever was the default when the app was first installed.
|
||||||
|
if (setting.isSetToDefault()) {
|
||||||
|
setting.removeFromPreferences();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the value of {@link #value}, but do not save to {@link #preferences}.
|
* Sets the value of {@link #value}, but do not save to {@link #preferences}.
|
||||||
*/
|
*/
|
||||||
protected abstract void setValueFromString(@NonNull String newValue);
|
protected abstract void setValueFromString(String newValue);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and set the value of {@link #value}.
|
* Load and set the value of {@link #value}.
|
||||||
@@ -303,16 +301,45 @@ public abstract class Setting<T> {
|
|||||||
/**
|
/**
|
||||||
* Persistently saves the value.
|
* Persistently saves the value.
|
||||||
*/
|
*/
|
||||||
public abstract void save(@NonNull T newValue);
|
public final void save(T newValue) {
|
||||||
|
if (value.equals(newValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
||||||
|
value = Objects.requireNonNull(newValue);
|
||||||
|
|
||||||
|
if (defaultValue.equals(newValue)) {
|
||||||
|
removeFromPreferences();
|
||||||
|
} else {
|
||||||
|
saveToPreferences();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save {@link #value} to {@link #preferences}.
|
||||||
|
*/
|
||||||
|
protected abstract void saveToPreferences();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove {@link #value} from {@link #preferences}.
|
||||||
|
*/
|
||||||
|
protected final void removeFromPreferences() {
|
||||||
|
Logger.printDebug(() -> "Clearing stored preference value (reset to default): " + key);
|
||||||
|
preferences.removeKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
public abstract T get();
|
public abstract T get();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identical to calling {@link #save(Object)} using {@link #defaultValue}.
|
* Identical to calling {@link #save(Object)} using {@link #defaultValue}.
|
||||||
|
*
|
||||||
|
* @return The newly saved default value.
|
||||||
*/
|
*/
|
||||||
public void resetToDefault() {
|
public T resetToDefault() {
|
||||||
save(defaultValue);
|
save(defaultValue);
|
||||||
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -371,7 +398,6 @@ public abstract class Setting<T> {
|
|||||||
json.put(importExportKey, value);
|
json.put(importExportKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
|
||||||
public static String exportToJson(@Nullable Context alertDialogContext) {
|
public static String exportToJson(@Nullable Context alertDialogContext) {
|
||||||
try {
|
try {
|
||||||
JSONObject json = new JSONObject();
|
JSONObject json = new JSONObject();
|
||||||
@@ -410,7 +436,7 @@ public abstract class Setting<T> {
|
|||||||
/**
|
/**
|
||||||
* @return if any settings that require a reboot were changed.
|
* @return if any settings that require a reboot were changed.
|
||||||
*/
|
*/
|
||||||
public static boolean importFromJSON(@NonNull Context alertDialogContext, @NonNull String settingsJsonString) {
|
public static boolean importFromJSON(Context alertDialogContext, String settingsJsonString) {
|
||||||
try {
|
try {
|
||||||
if (!settingsJsonString.matches("[\\s\\S]*\\{")) {
|
if (!settingsJsonString.matches("[\\s\\S]*\\{")) {
|
||||||
settingsJsonString = '{' + settingsJsonString + '}'; // Restore outer JSON braces
|
settingsJsonString = '{' + settingsJsonString + '}'; // Restore outer JSON braces
|
||||||
@@ -419,6 +445,7 @@ public abstract class Setting<T> {
|
|||||||
|
|
||||||
boolean rebootSettingChanged = false;
|
boolean rebootSettingChanged = false;
|
||||||
int numberOfSettingsImported = 0;
|
int numberOfSettingsImported = 0;
|
||||||
|
//noinspection rawtypes
|
||||||
for (Setting setting : SETTINGS) {
|
for (Setting setting : SETTINGS) {
|
||||||
String key = setting.getImportExportKey();
|
String key = setting.getImportExportKey();
|
||||||
if (json.has(key)) {
|
if (json.has(key)) {
|
||||||
|
|||||||
@@ -55,10 +55,8 @@ public class StringSetting extends Setting<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void save(@NonNull String newValue) {
|
public void saveToPreferences() {
|
||||||
// Must set before saving to preferences (otherwise importing fails to update UI correctly).
|
preferences.saveString(key, value);
|
||||||
value = Objects.requireNonNull(newValue);
|
|
||||||
preferences.saveString(key, newValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ package app.revanced.extension.shared.settings.preference;
|
|||||||
import static app.revanced.extension.shared.StringRef.str;
|
import static app.revanced.extension.shared.StringRef.str;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.app.AlertDialog;
|
import android.app.Dialog;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.preference.*;
|
import android.preference.Preference;
|
||||||
|
import android.preference.PreferenceFragment;
|
||||||
|
import android.preference.PreferenceGroup;
|
||||||
|
import android.preference.PreferenceManager;
|
||||||
|
import android.preference.PreferenceScreen;
|
||||||
|
import android.preference.SwitchPreference;
|
||||||
|
import android.preference.EditTextPreference;
|
||||||
|
import android.preference.ListPreference;
|
||||||
|
import android.util.Pair;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
@@ -22,6 +30,7 @@ import app.revanced.extension.shared.settings.Setting;
|
|||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
@SuppressWarnings("deprecation")
|
||||||
public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates that if a preference changes,
|
* Indicates that if a preference changes,
|
||||||
* to apply the change from the Setting to the UI component.
|
* to apply the change from the Setting to the UI component.
|
||||||
@@ -29,20 +38,30 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
|||||||
public static boolean settingImportInProgress;
|
public static boolean settingImportInProgress;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm and restart dialog button text and title.
|
* Prevents recursive calls during preference <-> UI syncing from showing extra dialogs.
|
||||||
* Set by subclasses if Strings cannot be added as a resource.
|
|
||||||
*/
|
*/
|
||||||
@Nullable
|
private static boolean updatingPreference;
|
||||||
protected static String restartDialogButtonText, restartDialogTitle, confirmDialogTitle;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to prevent showing reboot dialog, if user cancels a setting user dialog.
|
* Used to prevent showing reboot dialog, if user cancels a setting user dialog.
|
||||||
*/
|
*/
|
||||||
private boolean showingUserDialogMessage;
|
private static boolean showingUserDialogMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm and restart dialog button text and title.
|
||||||
|
* Set by subclasses if Strings cannot be added as a resource.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
protected static String restartDialogButtonText, restartDialogTitle, confirmDialogTitle, restartDialogMessage;
|
||||||
|
|
||||||
private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
|
private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
|
||||||
try {
|
try {
|
||||||
Setting<?> setting = Setting.getSettingFromPath(str);
|
if (updatingPreference) {
|
||||||
|
Logger.printDebug(() -> "Ignoring preference change as sync is in progress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Setting<?> setting = Setting.getSettingFromPath(Objects.requireNonNull(str));
|
||||||
if (setting == null) {
|
if (setting == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,29 +71,29 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
|||||||
}
|
}
|
||||||
Logger.printDebug(() -> "Preference changed: " + setting.key);
|
Logger.printDebug(() -> "Preference changed: " + setting.key);
|
||||||
|
|
||||||
// Apply 'Setting <- Preference', unless during importing when it needs to be 'Setting -> Preference'.
|
if (!settingImportInProgress && !showingUserDialogMessage) {
|
||||||
updatePreference(pref, setting, true, settingImportInProgress);
|
if (setting.userDialogMessage != null && !prefIsSetToDefault(pref, setting)) {
|
||||||
// Update any other preference availability that may now be different.
|
// Do not change the setting yet, to allow preserving whatever
|
||||||
updateUIAvailability();
|
// list/text value was previously set if it needs to be reverted.
|
||||||
|
showSettingUserDialogConfirmation(pref, setting);
|
||||||
if (settingImportInProgress) {
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showingUserDialogMessage) {
|
|
||||||
if (setting.userDialogMessage != null && ((SwitchPreference) pref).isChecked() != (Boolean) setting.defaultValue) {
|
|
||||||
showSettingUserDialogConfirmation((SwitchPreference) pref, (BooleanSetting) setting);
|
|
||||||
} else if (setting.rebootApp) {
|
} else if (setting.rebootApp) {
|
||||||
showRestartDialog(getContext());
|
showRestartDialog(getContext());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatingPreference = true;
|
||||||
|
// Apply 'Setting <- Preference', unless during importing when it needs to be 'Setting -> Preference'.
|
||||||
|
// Updating here can cause a recursive call back into this same method.
|
||||||
|
updatePreference(pref, setting, true, settingImportInProgress);
|
||||||
|
// Update any other preference availability that may now be different.
|
||||||
|
updateUIAvailability();
|
||||||
|
updatingPreference = false;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex);
|
Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize this instance, and do any custom behavior.
|
* Initialize this instance, and do any custom behavior.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -83,7 +102,10 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
|||||||
* so all app specific {@link Setting} instances are loaded before this method returns.
|
* so all app specific {@link Setting} instances are loaded before this method returns.
|
||||||
*/
|
*/
|
||||||
protected void initialize() {
|
protected void initialize() {
|
||||||
final var identifier = Utils.getResourceIdentifier("revanced_prefs", "xml");
|
String preferenceResourceName = BaseSettings.SHOW_MENU_ICONS.get()
|
||||||
|
? "revanced_prefs_icons"
|
||||||
|
: "revanced_prefs";
|
||||||
|
final var identifier = Utils.getResourceIdentifier(preferenceResourceName, "xml");
|
||||||
if (identifier == 0) return;
|
if (identifier == 0) return;
|
||||||
addPreferencesFromResource(identifier);
|
addPreferencesFromResource(identifier);
|
||||||
|
|
||||||
@@ -92,37 +114,53 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
|||||||
Utils.setPreferenceTitlesToMultiLineIfNeeded(screen);
|
Utils.setPreferenceTitlesToMultiLineIfNeeded(screen);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showSettingUserDialogConfirmation(SwitchPreference switchPref, BooleanSetting setting) {
|
private void showSettingUserDialogConfirmation(Preference pref, Setting<?> setting) {
|
||||||
Utils.verifyOnMainThread();
|
Utils.verifyOnMainThread();
|
||||||
|
|
||||||
final var context = getContext();
|
final var context = getContext();
|
||||||
if (confirmDialogTitle == null) {
|
if (confirmDialogTitle == null) {
|
||||||
confirmDialogTitle = str("revanced_settings_confirm_user_dialog_title");
|
confirmDialogTitle = str("revanced_settings_confirm_user_dialog_title");
|
||||||
}
|
}
|
||||||
|
|
||||||
showingUserDialogMessage = true;
|
showingUserDialogMessage = true;
|
||||||
new AlertDialog.Builder(context)
|
|
||||||
.setTitle(confirmDialogTitle)
|
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
|
||||||
.setMessage(Objects.requireNonNull(setting.userDialogMessage).toString())
|
context,
|
||||||
.setPositiveButton(android.R.string.ok, (dialog, id) -> {
|
confirmDialogTitle, // Title.
|
||||||
|
Objects.requireNonNull(setting.userDialogMessage).toString(), // No message.
|
||||||
|
null, // No EditText.
|
||||||
|
null, // OK button text.
|
||||||
|
() -> {
|
||||||
|
// OK button action. User confirmed, save to the Setting.
|
||||||
|
updatePreference(pref, setting, true, false);
|
||||||
|
|
||||||
|
// Update availability of other preferences that may be changed.
|
||||||
|
updateUIAvailability();
|
||||||
|
|
||||||
if (setting.rebootApp) {
|
if (setting.rebootApp) {
|
||||||
showRestartDialog(context);
|
showRestartDialog(context);
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
.setNegativeButton(android.R.string.cancel, (dialog, id) -> {
|
() -> {
|
||||||
switchPref.setChecked(setting.defaultValue); // Recursive call that resets the Setting value.
|
// Cancel button action. Restore whatever the setting was before the change.
|
||||||
})
|
updatePreference(pref, setting, true, true);
|
||||||
.setOnDismissListener(dialog -> {
|
},
|
||||||
showingUserDialogMessage = false;
|
null, // No Neutral button.
|
||||||
})
|
null, // No Neutral button action.
|
||||||
.setCancelable(false)
|
true // Dismiss dialog when onNeutralClick.
|
||||||
.show();
|
);
|
||||||
|
|
||||||
|
dialogPair.first.setOnDismissListener(d -> showingUserDialogMessage = false);
|
||||||
|
|
||||||
|
// Show the dialog.
|
||||||
|
dialogPair.first.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates all Preferences values and their availability using the current values in {@link Setting}.
|
* Updates all Preferences values and their availability using the current values in {@link Setting}.
|
||||||
*/
|
*/
|
||||||
protected void updateUIToSettingValues() {
|
protected void updateUIToSettingValues() {
|
||||||
updatePreferenceScreen(getPreferenceScreen(), true,true);
|
updatePreferenceScreen(getPreferenceScreen(), true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,19 +170,39 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
|||||||
updatePreferenceScreen(getPreferenceScreen(), false, false);
|
updatePreferenceScreen(getPreferenceScreen(), false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return If the preference is currently set to the default value of the Setting.
|
||||||
|
*/
|
||||||
|
protected boolean prefIsSetToDefault(Preference pref, Setting<?> setting) {
|
||||||
|
Object defaultValue = setting.defaultValue;
|
||||||
|
if (pref instanceof SwitchPreference switchPref) {
|
||||||
|
return switchPref.isChecked() == (Boolean) defaultValue;
|
||||||
|
}
|
||||||
|
String defaultValueString = defaultValue.toString();
|
||||||
|
if (pref instanceof EditTextPreference editPreference) {
|
||||||
|
return editPreference.getText().equals(defaultValueString);
|
||||||
|
}
|
||||||
|
if (pref instanceof ListPreference listPref) {
|
||||||
|
return listPref.getValue().equals(defaultValueString);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException("Must override method to handle "
|
||||||
|
+ "preference type: " + pref.getClass());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs all UI Preferences to any {@link Setting} they represent.
|
* Syncs all UI Preferences to any {@link Setting} they represent.
|
||||||
*/
|
*/
|
||||||
private void updatePreferenceScreen(@NonNull PreferenceScreen screen,
|
private void updatePreferenceScreen(@NonNull PreferenceGroup group,
|
||||||
boolean syncSettingValue,
|
boolean syncSettingValue,
|
||||||
boolean applySettingToPreference) {
|
boolean applySettingToPreference) {
|
||||||
// Alternatively this could iterate thru all Settings and check for any matching Preferences,
|
// Alternatively this could iterate thru all Settings and check for any matching Preferences,
|
||||||
// but there are many more Settings than UI preferences so it's more efficient to only check
|
// but there are many more Settings than UI preferences so it's more efficient to only check
|
||||||
// the Preferences.
|
// the Preferences.
|
||||||
for (int i = 0, prefCount = screen.getPreferenceCount(); i < prefCount; i++) {
|
for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
|
||||||
Preference pref = screen.getPreference(i);
|
Preference pref = group.getPreference(i);
|
||||||
if (pref instanceof PreferenceScreen) {
|
if (pref instanceof PreferenceGroup subGroup) {
|
||||||
updatePreferenceScreen((PreferenceScreen) pref, syncSettingValue, applySettingToPreference);
|
updatePreferenceScreen(subGroup, syncSettingValue, applySettingToPreference);
|
||||||
} else if (pref.hasKey()) {
|
} else if (pref.hasKey()) {
|
||||||
String key = pref.getKey();
|
String key = pref.getKey();
|
||||||
Setting<?> setting = Setting.getSettingFromPath(key);
|
Setting<?> setting = Setting.getSettingFromPath(key);
|
||||||
@@ -170,23 +228,20 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
|||||||
protected void syncSettingWithPreference(@NonNull Preference pref,
|
protected void syncSettingWithPreference(@NonNull Preference pref,
|
||||||
@NonNull Setting<?> setting,
|
@NonNull Setting<?> setting,
|
||||||
boolean applySettingToPreference) {
|
boolean applySettingToPreference) {
|
||||||
if (pref instanceof SwitchPreference) {
|
if (pref instanceof SwitchPreference switchPref) {
|
||||||
SwitchPreference switchPref = (SwitchPreference) pref;
|
|
||||||
BooleanSetting boolSetting = (BooleanSetting) setting;
|
BooleanSetting boolSetting = (BooleanSetting) setting;
|
||||||
if (applySettingToPreference) {
|
if (applySettingToPreference) {
|
||||||
switchPref.setChecked(boolSetting.get());
|
switchPref.setChecked(boolSetting.get());
|
||||||
} else {
|
} else {
|
||||||
BooleanSetting.privateSetValue(boolSetting, switchPref.isChecked());
|
BooleanSetting.privateSetValue(boolSetting, switchPref.isChecked());
|
||||||
}
|
}
|
||||||
} else if (pref instanceof EditTextPreference) {
|
} else if (pref instanceof EditTextPreference editPreference) {
|
||||||
EditTextPreference editPreference = (EditTextPreference) pref;
|
|
||||||
if (applySettingToPreference) {
|
if (applySettingToPreference) {
|
||||||
editPreference.setText(setting.get().toString());
|
editPreference.setText(setting.get().toString());
|
||||||
} else {
|
} else {
|
||||||
Setting.privateSetValueFromString(setting, editPreference.getText());
|
Setting.privateSetValueFromString(setting, editPreference.getText());
|
||||||
}
|
}
|
||||||
} else if (pref instanceof ListPreference) {
|
} else if (pref instanceof ListPreference listPref) {
|
||||||
ListPreference listPref = (ListPreference) pref;
|
|
||||||
if (applySettingToPreference) {
|
if (applySettingToPreference) {
|
||||||
listPref.setValue(setting.get().toString());
|
listPref.setValue(setting.get().toString());
|
||||||
} else {
|
} else {
|
||||||
@@ -235,21 +290,32 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void showRestartDialog(@NonNull final Context context) {
|
public static void showRestartDialog(Context context) {
|
||||||
Utils.verifyOnMainThread();
|
Utils.verifyOnMainThread();
|
||||||
if (restartDialogTitle == null) {
|
if (restartDialogTitle == null) {
|
||||||
restartDialogTitle = str("revanced_settings_restart_title");
|
restartDialogTitle = str("revanced_settings_restart_title");
|
||||||
}
|
}
|
||||||
|
if (restartDialogMessage == null) {
|
||||||
|
restartDialogMessage = str("revanced_settings_restart_dialog_message");
|
||||||
|
}
|
||||||
if (restartDialogButtonText == null) {
|
if (restartDialogButtonText == null) {
|
||||||
restartDialogButtonText = str("revanced_settings_restart");
|
restartDialogButtonText = str("revanced_settings_restart");
|
||||||
}
|
}
|
||||||
new AlertDialog.Builder(context)
|
|
||||||
.setMessage(restartDialogTitle)
|
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(context,
|
||||||
.setPositiveButton(restartDialogButtonText, (dialog, id)
|
restartDialogTitle, // Title.
|
||||||
-> Utils.restartApp(context))
|
restartDialogMessage, // Message.
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
null, // No EditText.
|
||||||
.setCancelable(false)
|
restartDialogButtonText, // OK button text.
|
||||||
.show();
|
() -> Utils.restartApp(context), // OK button action.
|
||||||
|
() -> {}, // Cancel button action (dismiss only).
|
||||||
|
null, // No Neutral button text.
|
||||||
|
null, // No Neutral button action.
|
||||||
|
true // Dismiss dialog when onNeutralClick.
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show the dialog.
|
||||||
|
dialogPair.first.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("ResourceType")
|
@SuppressLint("ResourceType")
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
package app.revanced.extension.shared.settings.preference;
|
||||||
|
|
||||||
|
import static app.revanced.extension.shared.StringRef.str;
|
||||||
|
import static app.revanced.extension.shared.Utils.getResourceIdentifier;
|
||||||
|
import static app.revanced.extension.shared.Utils.dipToPixels;
|
||||||
|
|
||||||
|
import android.app.Dialog;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.preference.EditTextPreference;
|
||||||
|
import android.text.Editable;
|
||||||
|
import android.text.InputType;
|
||||||
|
import android.text.SpannableString;
|
||||||
|
import android.text.Spanned;
|
||||||
|
import android.text.TextWatcher;
|
||||||
|
import android.text.style.ForegroundColorSpan;
|
||||||
|
import android.text.style.RelativeSizeSpan;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Pair;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.view.ViewParent;
|
||||||
|
import android.widget.*;
|
||||||
|
|
||||||
|
import androidx.annotation.ColorInt;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
import app.revanced.extension.shared.settings.Setting;
|
||||||
|
import app.revanced.extension.shared.settings.StringSetting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom preference for selecting a color via a hexadecimal code or a color picker dialog.
|
||||||
|
* Extends {@link EditTextPreference} to display a colored dot in the widget area,
|
||||||
|
* reflecting the currently selected color. The dot is dimmed when the preference is disabled.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings({"unused", "deprecation"})
|
||||||
|
public class ColorPickerPreference extends EditTextPreference {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character to show the color appearance.
|
||||||
|
*/
|
||||||
|
public static final String COLOR_DOT_STRING = "⬤";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Length of a valid color string of format #RRGGBB.
|
||||||
|
*/
|
||||||
|
public static final int COLOR_STRING_LENGTH = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches everything that is not a hex number/letter.
|
||||||
|
*/
|
||||||
|
private static final Pattern PATTERN_NOT_HEX = Pattern.compile("[^0-9A-Fa-f]");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alpha for dimming when the preference is disabled.
|
||||||
|
*/
|
||||||
|
private static final float DISABLED_ALPHA = 0.5f; // 50%
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View displaying a colored dot in the widget area.
|
||||||
|
*/
|
||||||
|
private View widgetColorDot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current color in RGB format (without alpha).
|
||||||
|
*/
|
||||||
|
@ColorInt
|
||||||
|
private int currentColor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associated setting for storing the color value.
|
||||||
|
*/
|
||||||
|
private StringSetting colorSetting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog TextWatcher for the EditText to monitor color input changes.
|
||||||
|
*/
|
||||||
|
private TextWatcher colorTextWatcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog TextView displaying a colored dot for the selected color preview in the dialog.
|
||||||
|
*/
|
||||||
|
private TextView dialogColorPreview;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog color picker view.
|
||||||
|
*/
|
||||||
|
private ColorPickerView dialogColorPickerView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes non valid hex characters, converts to all uppercase,
|
||||||
|
* and adds # character to the start if not present.
|
||||||
|
*/
|
||||||
|
public static String cleanupColorCodeString(String colorString) {
|
||||||
|
// Remove non-hex chars, convert to uppercase, and ensure correct length
|
||||||
|
String result = "#" + PATTERN_NOT_HEX.matcher(colorString)
|
||||||
|
.replaceAll("").toUpperCase(Locale.ROOT);
|
||||||
|
|
||||||
|
if (result.length() < COLOR_STRING_LENGTH) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.substring(0, COLOR_STRING_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param color RGB color, without an alpha channel.
|
||||||
|
* @return #RRGGBB hex color string
|
||||||
|
*/
|
||||||
|
public static String getColorString(@ColorInt int color) {
|
||||||
|
String colorString = String.format("#%06X", color);
|
||||||
|
if ((color & 0xFF000000) != 0) {
|
||||||
|
// Likely a bug somewhere.
|
||||||
|
Logger.printException(() -> "getColorString: color has alpha channel: " + colorString);
|
||||||
|
}
|
||||||
|
return colorString;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Spanned object for a colored dot using SpannableString.
|
||||||
|
*
|
||||||
|
* @param color The RGB color (without alpha).
|
||||||
|
* @return A Spanned object with the colored dot.
|
||||||
|
*/
|
||||||
|
public static Spanned getColorDot(@ColorInt int color) {
|
||||||
|
SpannableString spannable = new SpannableString(COLOR_DOT_STRING);
|
||||||
|
spannable.setSpan(new ForegroundColorSpan(color | 0xFF000000), 0, COLOR_DOT_STRING.length(),
|
||||||
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
spannable.setSpan(new RelativeSizeSpan(1.5f), 0, 1,
|
||||||
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
return spannable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ColorPickerPreference(Context context) {
|
||||||
|
super(context);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ColorPickerPreference(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the preference by setting up the EditText, loading the color, and set the widget layout.
|
||||||
|
*/
|
||||||
|
private void init() {
|
||||||
|
colorSetting = (StringSetting) Setting.getSettingFromPath(getKey());
|
||||||
|
if (colorSetting == null) {
|
||||||
|
Logger.printException(() -> "Could not find color setting for: " + getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
EditText editText = getEditText();
|
||||||
|
editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
|
||||||
|
| InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
editText.setAutofillHints((String) null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the widget layout to a custom layout containing the colored dot.
|
||||||
|
setWidgetLayoutResource(getResourceIdentifier("revanced_color_dot_widget", "layout"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the selected color and updates the UI and settings.
|
||||||
|
*
|
||||||
|
* @param colorString The color in hexadecimal format (e.g., "#RRGGBB").
|
||||||
|
* @throws IllegalArgumentException If the color string is invalid.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public final void setText(String colorString) {
|
||||||
|
try {
|
||||||
|
Logger.printDebug(() -> "setText: " + colorString);
|
||||||
|
super.setText(colorString);
|
||||||
|
|
||||||
|
currentColor = Color.parseColor(colorString) & 0x00FFFFFF;
|
||||||
|
if (colorSetting != null) {
|
||||||
|
colorSetting.save(getColorString(currentColor));
|
||||||
|
}
|
||||||
|
updateColorPreview();
|
||||||
|
updateWidgetColorDot();
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
// This code is reached if the user pastes settings json with an invalid color
|
||||||
|
// since this preference is updated with the new setting text.
|
||||||
|
Logger.printDebug(() -> "Parse color error: " + colorString, ex);
|
||||||
|
Utils.showToastShort(str("revanced_settings_color_invalid"));
|
||||||
|
setText(colorSetting.resetToDefault());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "setText failure: " + colorString, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onBindView(View view) {
|
||||||
|
super.onBindView(view);
|
||||||
|
|
||||||
|
widgetColorDot = view.findViewById(getResourceIdentifier(
|
||||||
|
"revanced_color_dot_widget", "id"));
|
||||||
|
widgetColorDot.setBackgroundResource(getResourceIdentifier(
|
||||||
|
"revanced_settings_circle_background", "drawable"));
|
||||||
|
widgetColorDot.getBackground().setTint(currentColor | 0xFF000000);
|
||||||
|
widgetColorDot.setAlpha(isEnabled() ? 1.0f : DISABLED_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the color preview TextView with a colored dot.
|
||||||
|
*/
|
||||||
|
private void updateColorPreview() {
|
||||||
|
if (dialogColorPreview != null) {
|
||||||
|
dialogColorPreview.setText(getColorDot(currentColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateWidgetColorDot() {
|
||||||
|
if (widgetColorDot != null) {
|
||||||
|
widgetColorDot.getBackground().setTint(currentColor | 0xFF000000);
|
||||||
|
widgetColorDot.setAlpha(isEnabled() ? 1.0f : DISABLED_ALPHA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a TextWatcher to monitor changes in the EditText for color input.
|
||||||
|
*
|
||||||
|
* @return A TextWatcher that updates the color preview on valid input.
|
||||||
|
*/
|
||||||
|
private TextWatcher createColorTextWatcher(ColorPickerView colorPickerView) {
|
||||||
|
return new TextWatcher() {
|
||||||
|
@Override
|
||||||
|
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterTextChanged(Editable edit) {
|
||||||
|
try {
|
||||||
|
String colorString = edit.toString();
|
||||||
|
|
||||||
|
String sanitizedColorString = cleanupColorCodeString(colorString);
|
||||||
|
if (!sanitizedColorString.equals(colorString)) {
|
||||||
|
edit.replace(0, colorString.length(), sanitizedColorString);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitizedColorString.length() != COLOR_STRING_LENGTH) {
|
||||||
|
// User is still typing out the color.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int newColor = Color.parseColor(colorString);
|
||||||
|
if (currentColor != newColor) {
|
||||||
|
Logger.printDebug(() -> "afterTextChanged: " + sanitizedColorString);
|
||||||
|
currentColor = newColor;
|
||||||
|
updateColorPreview();
|
||||||
|
updateWidgetColorDot();
|
||||||
|
colorPickerView.setColor(newColor);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// Should never be reached since input is validated before using.
|
||||||
|
Logger.printException(() -> "afterTextChanged failure", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Dialog with a color preview and EditText for hex color input.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void showDialog(Bundle state) {
|
||||||
|
Context context = getContext();
|
||||||
|
|
||||||
|
// Inflate color picker view.
|
||||||
|
View colorPicker = LayoutInflater.from(context).inflate(
|
||||||
|
getResourceIdentifier("revanced_color_picker", "layout"), null);
|
||||||
|
dialogColorPickerView = colorPicker.findViewById(
|
||||||
|
getResourceIdentifier("revanced_color_picker_view", "id"));
|
||||||
|
dialogColorPickerView.setColor(currentColor);
|
||||||
|
|
||||||
|
// Horizontal layout for preview and EditText.
|
||||||
|
LinearLayout inputLayout = new LinearLayout(context);
|
||||||
|
inputLayout.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
|
||||||
|
dialogColorPreview = new TextView(context);
|
||||||
|
LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
);
|
||||||
|
previewParams.setMargins(dipToPixels(15), 0, dipToPixels(10), 0); // text dot has its own indents so 15, instead 16.
|
||||||
|
dialogColorPreview.setLayoutParams(previewParams);
|
||||||
|
inputLayout.addView(dialogColorPreview);
|
||||||
|
updateColorPreview();
|
||||||
|
|
||||||
|
EditText editText = getEditText();
|
||||||
|
ViewParent parent = editText.getParent();
|
||||||
|
if (parent instanceof ViewGroup parentViewGroup) {
|
||||||
|
parentViewGroup.removeView(editText);
|
||||||
|
}
|
||||||
|
editText.setLayoutParams(new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
));
|
||||||
|
String currentColorString = getColorString(currentColor);
|
||||||
|
editText.setText(currentColorString);
|
||||||
|
editText.setSelection(currentColorString.length());
|
||||||
|
editText.setTypeface(Typeface.MONOSPACE);
|
||||||
|
colorTextWatcher = createColorTextWatcher(dialogColorPickerView);
|
||||||
|
editText.addTextChangedListener(colorTextWatcher);
|
||||||
|
inputLayout.addView(editText);
|
||||||
|
|
||||||
|
// Add a dummy view to take up remaining horizontal space,
|
||||||
|
// otherwise it will show an oversize underlined text view.
|
||||||
|
View paddingView = new View(context);
|
||||||
|
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||||
|
0,
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
1f
|
||||||
|
);
|
||||||
|
paddingView.setLayoutParams(params);
|
||||||
|
inputLayout.addView(paddingView);
|
||||||
|
|
||||||
|
// Create content container for color picker and input layout.
|
||||||
|
LinearLayout contentContainer = new LinearLayout(context);
|
||||||
|
contentContainer.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
contentContainer.addView(colorPicker);
|
||||||
|
contentContainer.addView(inputLayout);
|
||||||
|
|
||||||
|
// Create ScrollView to wrap the content container.
|
||||||
|
ScrollView contentScrollView = new ScrollView(context);
|
||||||
|
contentScrollView.setVerticalScrollBarEnabled(false); // Disable vertical scrollbar.
|
||||||
|
contentScrollView.setOverScrollMode(View.OVER_SCROLL_NEVER); // Disable overscroll effect.
|
||||||
|
LinearLayout.LayoutParams scrollViewParams = new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
0,
|
||||||
|
1.0f
|
||||||
|
);
|
||||||
|
contentScrollView.setLayoutParams(scrollViewParams);
|
||||||
|
contentScrollView.addView(contentContainer);
|
||||||
|
|
||||||
|
// Create custom dialog.
|
||||||
|
final int originalColor = currentColor & 0x00FFFFFF;
|
||||||
|
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
|
||||||
|
context,
|
||||||
|
getTitle() != null ? getTitle().toString() : str("revanced_settings_color_picker_title"), // Title.
|
||||||
|
null, // No message.
|
||||||
|
null, // No EditText.
|
||||||
|
null, // OK button text.
|
||||||
|
() -> {
|
||||||
|
// OK button action.
|
||||||
|
try {
|
||||||
|
String colorString = editText.getText().toString();
|
||||||
|
if (colorString.length() != COLOR_STRING_LENGTH) {
|
||||||
|
Utils.showToastShort(str("revanced_settings_color_invalid"));
|
||||||
|
setText(getColorString(originalColor));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setText(colorString);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// Should never happen due to a bad color string,
|
||||||
|
// since the text is validated and fixed while the user types.
|
||||||
|
Logger.printException(() -> "OK button failure", ex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() -> {
|
||||||
|
// Cancel button action.
|
||||||
|
try {
|
||||||
|
// Restore the original color.
|
||||||
|
setText(getColorString(originalColor));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "Cancel button failure", ex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
str("revanced_settings_reset_color"), // Neutral button text.
|
||||||
|
() -> {
|
||||||
|
// Neutral button action.
|
||||||
|
try {
|
||||||
|
final int defaultColor = Color.parseColor(colorSetting.defaultValue) & 0x00FFFFFF;
|
||||||
|
// Setting view color causes listener callback into this class.
|
||||||
|
dialogColorPickerView.setColor(defaultColor);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "Reset button failure", ex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false // Do not dismiss dialog when onNeutralClick.
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the ScrollView to the dialog's main layout.
|
||||||
|
LinearLayout dialogMainLayout = dialogPair.second;
|
||||||
|
dialogMainLayout.addView(contentScrollView, dialogMainLayout.getChildCount() - 1);
|
||||||
|
|
||||||
|
// Set up color picker listener with debouncing.
|
||||||
|
// Add listener last to prevent callbacks from set calls above.
|
||||||
|
dialogColorPickerView.setOnColorChangedListener(color -> {
|
||||||
|
// Check if it actually changed, since this callback
|
||||||
|
// can be caused by updates in afterTextChanged().
|
||||||
|
if (currentColor == color) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String updatedColorString = getColorString(color);
|
||||||
|
Logger.printDebug(() -> "onColorChanged: " + updatedColorString);
|
||||||
|
currentColor = color;
|
||||||
|
editText.setText(updatedColorString);
|
||||||
|
editText.setSelection(updatedColorString.length());
|
||||||
|
|
||||||
|
updateColorPreview();
|
||||||
|
updateWidgetColorDot();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure and show the dialog.
|
||||||
|
Dialog dialog = dialogPair.first;
|
||||||
|
dialog.setCanceledOnTouchOutside(false);
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDialogClosed(boolean positiveResult) {
|
||||||
|
super.onDialogClosed(positiveResult);
|
||||||
|
|
||||||
|
if (colorTextWatcher != null) {
|
||||||
|
getEditText().removeTextChangedListener(colorTextWatcher);
|
||||||
|
colorTextWatcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogColorPreview = null;
|
||||||
|
dialogColorPickerView = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
super.setEnabled(enabled);
|
||||||
|
updateWidgetColorDot();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
package app.revanced.extension.shared.settings.preference;
|
||||||
|
|
||||||
|
import static app.revanced.extension.shared.Utils.dipToPixels;
|
||||||
|
import static app.revanced.extension.shared.settings.preference.ColorPickerPreference.getColorString;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.ComposeShader;
|
||||||
|
import android.graphics.LinearGradient;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.PorterDuff;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.graphics.Shader;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.ColorInt;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom color picker view that allows the user to select a color using a hue slider and a saturation-value selector.
|
||||||
|
* This implementation is density-independent and responsive across different screen sizes and DPIs.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This view displays two main components for color selection:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Hue Bar:</b> A horizontal bar at the bottom that allows the user to select the hue component of the color.
|
||||||
|
* <li><b>Saturation-Value Selector:</b> A rectangular area above the hue bar that allows the user to select the saturation and value (brightness)
|
||||||
|
* components of the color based on the selected hue.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The view uses {@link LinearGradient} and {@link ComposeShader} to create the color gradients for the hue bar and the
|
||||||
|
* saturation-value selector. It also uses {@link Paint} to draw the selectors (draggable handles).
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The selected color can be retrieved using {@link #getColor()} and can be set using {@link #setColor(int)}.
|
||||||
|
* An {@link OnColorChangedListener} can be registered to receive notifications when the selected color changes.
|
||||||
|
*/
|
||||||
|
public class ColorPickerView extends View {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface definition for a callback to be invoked when the selected color changes.
|
||||||
|
*/
|
||||||
|
public interface OnColorChangedListener {
|
||||||
|
/**
|
||||||
|
* Called when the selected color has changed.
|
||||||
|
*
|
||||||
|
* Important: Callback color uses RGB format with zero alpha channel.
|
||||||
|
*
|
||||||
|
* @param color The new selected color.
|
||||||
|
*/
|
||||||
|
void onColorChanged(@ColorInt int color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expanded touch area for the hue bar to increase the touch-sensitive area. */
|
||||||
|
public static final float TOUCH_EXPANSION = dipToPixels(20f);
|
||||||
|
|
||||||
|
private static final float MARGIN_BETWEEN_AREAS = dipToPixels(24);
|
||||||
|
private static final float VIEW_PADDING = dipToPixels(16);
|
||||||
|
private static final float HUE_BAR_HEIGHT = dipToPixels(12);
|
||||||
|
private static final float HUE_CORNER_RADIUS = dipToPixels(6);
|
||||||
|
private static final float SELECTOR_RADIUS = dipToPixels(12);
|
||||||
|
private static final float SELECTOR_STROKE_WIDTH = 8;
|
||||||
|
/**
|
||||||
|
* Hue fill radius. Use slightly smaller radius for the selector handle fill,
|
||||||
|
* otherwise the anti-aliasing causes the fill color to bleed past the selector outline.
|
||||||
|
*/
|
||||||
|
private static final float SELECTOR_FILL_RADIUS = SELECTOR_RADIUS - SELECTOR_STROKE_WIDTH / 2;
|
||||||
|
/** Thin dark outline stroke width for the selector rings. */
|
||||||
|
private static final float SELECTOR_EDGE_STROKE_WIDTH = 1;
|
||||||
|
public static final float SELECTOR_EDGE_RADIUS =
|
||||||
|
SELECTOR_RADIUS + SELECTOR_STROKE_WIDTH / 2 + SELECTOR_EDGE_STROKE_WIDTH / 2;
|
||||||
|
|
||||||
|
/** Selector outline inner color. */
|
||||||
|
@ColorInt
|
||||||
|
private static final int SELECTOR_OUTLINE_COLOR = Color.WHITE;
|
||||||
|
|
||||||
|
/** Dark edge color for the selector rings. */
|
||||||
|
@ColorInt
|
||||||
|
private static final int SELECTOR_EDGE_COLOR = Color.parseColor("#CFCFCF");
|
||||||
|
|
||||||
|
private static final int[] HUE_COLORS = new int[361];
|
||||||
|
static {
|
||||||
|
for (int i = 0; i < 361; i++) {
|
||||||
|
HUE_COLORS[i] = Color.HSVToColor(new float[]{i, 1, 1});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hue bar. */
|
||||||
|
private final Paint huePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
/** Saturation-value selector. */
|
||||||
|
private final Paint saturationValuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
/** Draggable selector. */
|
||||||
|
private final Paint selectorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
{
|
||||||
|
selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounds of the hue bar. */
|
||||||
|
private final RectF hueRect = new RectF();
|
||||||
|
/** Bounds of the saturation-value selector. */
|
||||||
|
private final RectF saturationValueRect = new RectF();
|
||||||
|
|
||||||
|
/** HSV color calculations to avoid allocations during drawing. */
|
||||||
|
private final float[] hsvArray = {1, 1, 1};
|
||||||
|
|
||||||
|
/** Current hue value (0-360). */
|
||||||
|
private float hue = 0f;
|
||||||
|
/** Current saturation value (0-1). */
|
||||||
|
private float saturation = 1f;
|
||||||
|
/** Current value (brightness) value (0-1). */
|
||||||
|
private float value = 1f;
|
||||||
|
|
||||||
|
/** The currently selected color in RGB format with no alpha channel. */
|
||||||
|
@ColorInt
|
||||||
|
private int selectedColor;
|
||||||
|
|
||||||
|
private OnColorChangedListener colorChangedListener;
|
||||||
|
|
||||||
|
/** Track if we're currently dragging the hue or saturation handle. */
|
||||||
|
private boolean isDraggingHue;
|
||||||
|
private boolean isDraggingSaturation;
|
||||||
|
|
||||||
|
public ColorPickerView(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ColorPickerView(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ColorPickerView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
|
final float DESIRED_ASPECT_RATIO = 0.8f; // height = width * 0.8
|
||||||
|
|
||||||
|
final int minWidth = Utils.dipToPixels(250);
|
||||||
|
final int minHeight = (int) (minWidth * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS);
|
||||||
|
|
||||||
|
int width = resolveSize(minWidth, widthMeasureSpec);
|
||||||
|
int height = resolveSize(minHeight, heightMeasureSpec);
|
||||||
|
|
||||||
|
// Ensure minimum dimensions for usability.
|
||||||
|
width = Math.max(width, minWidth);
|
||||||
|
height = Math.max(height, minHeight);
|
||||||
|
|
||||||
|
// Adjust height to maintain desired aspect ratio if possible.
|
||||||
|
final int desiredHeight = (int) (width * DESIRED_ASPECT_RATIO) + (int) (HUE_BAR_HEIGHT + MARGIN_BETWEEN_AREAS);
|
||||||
|
if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) {
|
||||||
|
height = desiredHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMeasuredDimension(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the size of the view changes.
|
||||||
|
* This method calculates and sets the bounds of the hue bar and saturation-value selector.
|
||||||
|
* It also creates the necessary shaders for the gradients.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
|
||||||
|
super.onSizeChanged(width, height, oldWidth, oldHeight);
|
||||||
|
|
||||||
|
// Calculate bounds with hue bar at the bottom.
|
||||||
|
final float effectiveWidth = width - (2 * VIEW_PADDING);
|
||||||
|
final float effectiveHeight = height - (2 * VIEW_PADDING) - HUE_BAR_HEIGHT - MARGIN_BETWEEN_AREAS;
|
||||||
|
|
||||||
|
// Adjust rectangles to account for padding and density-independent dimensions.
|
||||||
|
saturationValueRect.set(
|
||||||
|
VIEW_PADDING,
|
||||||
|
VIEW_PADDING,
|
||||||
|
VIEW_PADDING + effectiveWidth,
|
||||||
|
VIEW_PADDING + effectiveHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
hueRect.set(
|
||||||
|
VIEW_PADDING,
|
||||||
|
height - VIEW_PADDING - HUE_BAR_HEIGHT,
|
||||||
|
VIEW_PADDING + effectiveWidth,
|
||||||
|
height - VIEW_PADDING
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the shaders.
|
||||||
|
updateHueShader();
|
||||||
|
updateSaturationValueShader();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the hue full spectrum (0-360 degrees).
|
||||||
|
*/
|
||||||
|
private void updateHueShader() {
|
||||||
|
LinearGradient hueShader = new LinearGradient(
|
||||||
|
hueRect.left, hueRect.top,
|
||||||
|
hueRect.right, hueRect.top,
|
||||||
|
HUE_COLORS,
|
||||||
|
null,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
huePaint.setShader(hueShader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the shader for the saturation-value selector based on the currently selected hue.
|
||||||
|
* This method creates a combined shader that blends a saturation gradient with a value gradient.
|
||||||
|
*/
|
||||||
|
private void updateSaturationValueShader() {
|
||||||
|
// Create a saturation-value gradient based on the current hue.
|
||||||
|
// Calculate the start color (white with the selected hue) for the saturation gradient.
|
||||||
|
final int startColor = Color.HSVToColor(new float[]{hue, 0f, 1f});
|
||||||
|
|
||||||
|
// Calculate the middle color (fully saturated color with the selected hue) for the saturation gradient.
|
||||||
|
final int midColor = Color.HSVToColor(new float[]{hue, 1f, 1f});
|
||||||
|
|
||||||
|
// Create a linear gradient for the saturation from startColor to midColor (horizontal).
|
||||||
|
LinearGradient satShader = new LinearGradient(
|
||||||
|
saturationValueRect.left, saturationValueRect.top,
|
||||||
|
saturationValueRect.right, saturationValueRect.top,
|
||||||
|
startColor,
|
||||||
|
midColor,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a linear gradient for the value (brightness) from white to black (vertical).
|
||||||
|
//noinspection ExtractMethodRecommender
|
||||||
|
LinearGradient valShader = new LinearGradient(
|
||||||
|
saturationValueRect.left, saturationValueRect.top,
|
||||||
|
saturationValueRect.left, saturationValueRect.bottom,
|
||||||
|
Color.WHITE,
|
||||||
|
Color.BLACK,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine the saturation and value shaders using PorterDuff.Mode.MULTIPLY to create the final color.
|
||||||
|
ComposeShader combinedShader = new ComposeShader(satShader, valShader, PorterDuff.Mode.MULTIPLY);
|
||||||
|
|
||||||
|
// Set the combined shader for the saturation-value paint.
|
||||||
|
saturationValuePaint.setShader(combinedShader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the color picker view on the canvas.
|
||||||
|
* This method draws the saturation-value selector, the hue bar with rounded corners,
|
||||||
|
* and the draggable handles.
|
||||||
|
*
|
||||||
|
* @param canvas The canvas on which to draw.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
// Draw the saturation-value selector rectangle.
|
||||||
|
canvas.drawRect(saturationValueRect, saturationValuePaint);
|
||||||
|
|
||||||
|
// Draw the hue bar.
|
||||||
|
canvas.drawRoundRect(hueRect, HUE_CORNER_RADIUS, HUE_CORNER_RADIUS, huePaint);
|
||||||
|
|
||||||
|
final float hueSelectorX = hueRect.left + (hue / 360f) * hueRect.width();
|
||||||
|
final float hueSelectorY = hueRect.centerY();
|
||||||
|
|
||||||
|
final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width();
|
||||||
|
final float satSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height();
|
||||||
|
|
||||||
|
// Draw the saturation and hue selector handle filled with the selected color.
|
||||||
|
hsvArray[0] = hue;
|
||||||
|
final int hueHandleColor = Color.HSVToColor(0xFF, hsvArray);
|
||||||
|
selectorPaint.setStyle(Paint.Style.FILL_AND_STROKE);
|
||||||
|
|
||||||
|
selectorPaint.setColor(hueHandleColor);
|
||||||
|
canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
|
||||||
|
|
||||||
|
selectorPaint.setColor(selectedColor | 0xFF000000);
|
||||||
|
canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_FILL_RADIUS, selectorPaint);
|
||||||
|
|
||||||
|
// Draw white outlines for the handles.
|
||||||
|
selectorPaint.setColor(SELECTOR_OUTLINE_COLOR);
|
||||||
|
selectorPaint.setStyle(Paint.Style.STROKE);
|
||||||
|
selectorPaint.setStrokeWidth(SELECTOR_STROKE_WIDTH);
|
||||||
|
canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_RADIUS, selectorPaint);
|
||||||
|
canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_RADIUS, selectorPaint);
|
||||||
|
|
||||||
|
// Draw thin dark outlines for the handles at the outer edge of the white outline.
|
||||||
|
selectorPaint.setColor(SELECTOR_EDGE_COLOR);
|
||||||
|
selectorPaint.setStrokeWidth(SELECTOR_EDGE_STROKE_WIDTH);
|
||||||
|
canvas.drawCircle(hueSelectorX, hueSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint);
|
||||||
|
canvas.drawCircle(satSelectorX, satSelectorY, SELECTOR_EDGE_RADIUS, selectorPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles touch events on the view.
|
||||||
|
* This method determines whether the touch event occurred within the hue bar or the saturation-value selector,
|
||||||
|
* updates the corresponding values (hue, saturation, value), and invalidates the view to trigger a redraw.
|
||||||
|
* <p>
|
||||||
|
* In addition to testing if the touch is within the strict rectangles, an expanded hit area (by selectorRadius)
|
||||||
|
* is used so that the draggable handles remain active even when half of the handle is outside the drawn bounds.
|
||||||
|
*
|
||||||
|
* @param event The motion event.
|
||||||
|
* @return True if the event was handled, false otherwise.
|
||||||
|
*/
|
||||||
|
@SuppressLint("ClickableViewAccessibility") // performClick is not overridden, but not needed in this case.
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(MotionEvent event) {
|
||||||
|
try {
|
||||||
|
final float x = event.getX();
|
||||||
|
final float y = event.getY();
|
||||||
|
final int action = event.getAction();
|
||||||
|
Logger.printDebug(() -> "onTouchEvent action: " + action + " x: " + x + " y: " + y);
|
||||||
|
|
||||||
|
// Define touch expansion for the hue bar.
|
||||||
|
RectF expandedHueRect = new RectF(
|
||||||
|
hueRect.left,
|
||||||
|
hueRect.top - TOUCH_EXPANSION,
|
||||||
|
hueRect.right,
|
||||||
|
hueRect.bottom + TOUCH_EXPANSION
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case MotionEvent.ACTION_DOWN:
|
||||||
|
// Calculate current handle positions.
|
||||||
|
final float hueSelectorX = hueRect.left + (hue / 360f) * hueRect.width();
|
||||||
|
final float hueSelectorY = hueRect.centerY();
|
||||||
|
|
||||||
|
final float satSelectorX = saturationValueRect.left + saturation * saturationValueRect.width();
|
||||||
|
final float valSelectorY = saturationValueRect.top + (1 - value) * saturationValueRect.height();
|
||||||
|
|
||||||
|
// Create hit areas for both handles.
|
||||||
|
RectF hueHitRect = new RectF(
|
||||||
|
hueSelectorX - SELECTOR_RADIUS,
|
||||||
|
hueSelectorY - SELECTOR_RADIUS,
|
||||||
|
hueSelectorX + SELECTOR_RADIUS,
|
||||||
|
hueSelectorY + SELECTOR_RADIUS
|
||||||
|
);
|
||||||
|
RectF satValHitRect = new RectF(
|
||||||
|
satSelectorX - SELECTOR_RADIUS,
|
||||||
|
valSelectorY - SELECTOR_RADIUS,
|
||||||
|
satSelectorX + SELECTOR_RADIUS,
|
||||||
|
valSelectorY + SELECTOR_RADIUS
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the touch started on a handle or within the expanded hue bar area.
|
||||||
|
if (hueHitRect.contains(x, y)) {
|
||||||
|
isDraggingHue = true;
|
||||||
|
updateHueFromTouch(x);
|
||||||
|
} else if (satValHitRect.contains(x, y)) {
|
||||||
|
isDraggingSaturation = true;
|
||||||
|
updateSaturationValueFromTouch(x, y);
|
||||||
|
} else if (expandedHueRect.contains(x, y)) {
|
||||||
|
// Handle touch within the expanded hue bar area.
|
||||||
|
isDraggingHue = true;
|
||||||
|
updateHueFromTouch(x);
|
||||||
|
} else if (saturationValueRect.contains(x, y)) {
|
||||||
|
isDraggingSaturation = true;
|
||||||
|
updateSaturationValueFromTouch(x, y);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MotionEvent.ACTION_MOVE:
|
||||||
|
// Continue updating values even if touch moves outside the view.
|
||||||
|
if (isDraggingHue) {
|
||||||
|
updateHueFromTouch(x);
|
||||||
|
} else if (isDraggingSaturation) {
|
||||||
|
updateSaturationValueFromTouch(x, y);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MotionEvent.ACTION_UP:
|
||||||
|
case MotionEvent.ACTION_CANCEL:
|
||||||
|
isDraggingHue = false;
|
||||||
|
isDraggingSaturation = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "onTouchEvent failure", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the hue value based on touch position, clamping to valid range.
|
||||||
|
*
|
||||||
|
* @param x The x-coordinate of the touch position.
|
||||||
|
*/
|
||||||
|
private void updateHueFromTouch(float x) {
|
||||||
|
// Clamp x to the hue rectangle bounds.
|
||||||
|
final float clampedX = Utils.clamp(x, hueRect.left, hueRect.right);
|
||||||
|
final float updatedHue = ((clampedX - hueRect.left) / hueRect.width()) * 360f;
|
||||||
|
if (hue == updatedHue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hue = updatedHue;
|
||||||
|
updateSaturationValueShader();
|
||||||
|
updateSelectedColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates saturation and value based on touch position, clamping to valid range.
|
||||||
|
*
|
||||||
|
* @param x The x-coordinate of the touch position.
|
||||||
|
* @param y The y-coordinate of the touch position.
|
||||||
|
*/
|
||||||
|
private void updateSaturationValueFromTouch(float x, float y) {
|
||||||
|
// Clamp x and y to the saturation-value rectangle bounds.
|
||||||
|
final float clampedX = Utils.clamp(x, saturationValueRect.left, saturationValueRect.right);
|
||||||
|
final float clampedY = Utils.clamp(y, saturationValueRect.top, saturationValueRect.bottom);
|
||||||
|
|
||||||
|
final float updatedSaturation = (clampedX - saturationValueRect.left) / saturationValueRect.width();
|
||||||
|
final float updatedValue = 1 - ((clampedY - saturationValueRect.top) / saturationValueRect.height());
|
||||||
|
|
||||||
|
if (saturation == updatedSaturation && value == updatedValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saturation = updatedSaturation;
|
||||||
|
value = updatedValue;
|
||||||
|
updateSelectedColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the selected color and notifies listeners.
|
||||||
|
*/
|
||||||
|
private void updateSelectedColor() {
|
||||||
|
final int updatedColor = Color.HSVToColor(0, new float[]{hue, saturation, value});
|
||||||
|
|
||||||
|
if (selectedColor != updatedColor) {
|
||||||
|
selectedColor = updatedColor;
|
||||||
|
|
||||||
|
if (colorChangedListener != null) {
|
||||||
|
colorChangedListener.onColorChanged(updatedColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must always redraw, otherwise if saturation is pure grey or black
|
||||||
|
// then the hue slider cannot be changed.
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the currently selected color.
|
||||||
|
*
|
||||||
|
* @param color The color to set in either ARGB or RGB format.
|
||||||
|
*/
|
||||||
|
public void setColor(@ColorInt int color) {
|
||||||
|
color &= 0x00FFFFFF;
|
||||||
|
if (selectedColor == color) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the selected color.
|
||||||
|
selectedColor = color;
|
||||||
|
Logger.printDebug(() -> "setColor: " + getColorString(selectedColor));
|
||||||
|
|
||||||
|
// Convert the ARGB color to HSV values.
|
||||||
|
float[] hsv = new float[3];
|
||||||
|
Color.colorToHSV(color, hsv);
|
||||||
|
|
||||||
|
// Update the hue, saturation, and value.
|
||||||
|
hue = hsv[0];
|
||||||
|
saturation = hsv[1];
|
||||||
|
value = hsv[2];
|
||||||
|
|
||||||
|
// Update the saturation-value shader based on the new hue.
|
||||||
|
updateSaturationValueShader();
|
||||||
|
|
||||||
|
// Notify the listener if it's set.
|
||||||
|
if (colorChangedListener != null) {
|
||||||
|
colorChangedListener.onColorChanged(selectedColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the view to trigger a redraw.
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the currently selected color.
|
||||||
|
*
|
||||||
|
* @return The selected color in RGB format with no alpha channel.
|
||||||
|
*/
|
||||||
|
@ColorInt
|
||||||
|
public int getColor() {
|
||||||
|
return selectedColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the listener to be notified when the selected color changes.
|
||||||
|
*
|
||||||
|
* @param listener The listener to set.
|
||||||
|
*/
|
||||||
|
public void setOnColorChangedListener(OnColorChangedListener listener) {
|
||||||
|
colorChangedListener = listener;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package app.revanced.extension.shared.settings.preference;
|
||||||
|
|
||||||
|
import static app.revanced.extension.shared.Utils.dipToPixels;
|
||||||
|
|
||||||
|
import android.app.Dialog;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.preference.ListPreference;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Pair;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.*;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom ListPreference that uses a styled custom dialog with a custom checkmark indicator.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings({"unused", "deprecation"})
|
||||||
|
public class CustomDialogListPreference extends ListPreference {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom ArrayAdapter to handle checkmark visibility.
|
||||||
|
*/
|
||||||
|
private static class ListPreferenceArrayAdapter extends ArrayAdapter<CharSequence> {
|
||||||
|
private static class SubViewDataContainer {
|
||||||
|
ImageView checkIcon;
|
||||||
|
View placeholder;
|
||||||
|
TextView itemText;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int layoutResourceId;
|
||||||
|
final CharSequence[] entryValues;
|
||||||
|
String selectedValue;
|
||||||
|
|
||||||
|
public ListPreferenceArrayAdapter(Context context, int resource, CharSequence[] entries,
|
||||||
|
CharSequence[] entryValues, String selectedValue) {
|
||||||
|
super(context, resource, entries);
|
||||||
|
this.layoutResourceId = resource;
|
||||||
|
this.entryValues = entryValues;
|
||||||
|
this.selectedValue = selectedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public View getView(int position, View convertView, @NonNull ViewGroup parent) {
|
||||||
|
View view = convertView;
|
||||||
|
SubViewDataContainer holder;
|
||||||
|
|
||||||
|
if (view == null) {
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(getContext());
|
||||||
|
view = inflater.inflate(layoutResourceId, parent, false);
|
||||||
|
holder = new SubViewDataContainer();
|
||||||
|
holder.checkIcon = view.findViewById(Utils.getResourceIdentifier(
|
||||||
|
"revanced_check_icon", "id"));
|
||||||
|
holder.placeholder = view.findViewById(Utils.getResourceIdentifier(
|
||||||
|
"revanced_check_icon_placeholder", "id"));
|
||||||
|
holder.itemText = view.findViewById(Utils.getResourceIdentifier(
|
||||||
|
"revanced_item_text", "id"));
|
||||||
|
view.setTag(holder);
|
||||||
|
} else {
|
||||||
|
holder = (SubViewDataContainer) view.getTag();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set text.
|
||||||
|
holder.itemText.setText(getItem(position));
|
||||||
|
holder.itemText.setTextColor(Utils.getAppForegroundColor());
|
||||||
|
|
||||||
|
// Show or hide checkmark and placeholder.
|
||||||
|
String currentValue = entryValues[position].toString();
|
||||||
|
boolean isSelected = currentValue.equals(selectedValue);
|
||||||
|
holder.checkIcon.setVisibility(isSelected ? View.VISIBLE : View.GONE);
|
||||||
|
holder.checkIcon.setColorFilter(Utils.getAppForegroundColor());
|
||||||
|
holder.placeholder.setVisibility(isSelected ? View.GONE : View.VISIBLE);
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSelectedValue(String value) {
|
||||||
|
this.selectedValue = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CustomDialogListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||||
|
super(context, attrs, defStyleAttr, defStyleRes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CustomDialogListPreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CustomDialogListPreference(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CustomDialogListPreference(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void showDialog(Bundle state) {
|
||||||
|
Context context = getContext();
|
||||||
|
|
||||||
|
// Create ListView.
|
||||||
|
ListView listView = new ListView(context);
|
||||||
|
listView.setId(android.R.id.list);
|
||||||
|
listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
|
||||||
|
|
||||||
|
// Create custom adapter for the ListView.
|
||||||
|
ListPreferenceArrayAdapter adapter = new ListPreferenceArrayAdapter(
|
||||||
|
context,
|
||||||
|
Utils.getResourceIdentifier("revanced_custom_list_item_checked", "layout"),
|
||||||
|
getEntries(),
|
||||||
|
getEntryValues(),
|
||||||
|
getValue()
|
||||||
|
);
|
||||||
|
listView.setAdapter(adapter);
|
||||||
|
|
||||||
|
// Set checked item.
|
||||||
|
String currentValue = getValue();
|
||||||
|
if (currentValue != null) {
|
||||||
|
CharSequence[] entryValues = getEntryValues();
|
||||||
|
for (int i = 0, length = entryValues.length; i < length; i++) {
|
||||||
|
if (currentValue.equals(entryValues[i].toString())) {
|
||||||
|
listView.setItemChecked(i, true);
|
||||||
|
listView.setSelection(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the custom dialog without OK button.
|
||||||
|
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
|
||||||
|
context,
|
||||||
|
getTitle() != null ? getTitle().toString() : "",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null, // No OK button text.
|
||||||
|
null, // No OK button action.
|
||||||
|
() -> {}, // Cancel button action (just dismiss).
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the ListView to the main layout.
|
||||||
|
LinearLayout mainLayout = dialogPair.second;
|
||||||
|
LinearLayout.LayoutParams listViewParams = new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
0,
|
||||||
|
1.0f
|
||||||
|
);
|
||||||
|
mainLayout.addView(listView, mainLayout.getChildCount() - 1, listViewParams);
|
||||||
|
|
||||||
|
// Handle item click to select value and dismiss dialog.
|
||||||
|
listView.setOnItemClickListener((parent, view, position, id) -> {
|
||||||
|
String selectedValue = getEntryValues()[position].toString();
|
||||||
|
if (callChangeListener(selectedValue)) {
|
||||||
|
setValue(selectedValue);
|
||||||
|
adapter.setSelectedValue(selectedValue);
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
dialogPair.first.dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the dialog.
|
||||||
|
dialogPair.first.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
package app.revanced.extension.shared.settings.preference;
|
package app.revanced.extension.shared.settings.preference;
|
||||||
|
|
||||||
import android.app.AlertDialog;
|
import static app.revanced.extension.shared.StringRef.str;
|
||||||
|
import static app.revanced.extension.shared.Utils.dipToPixels;
|
||||||
|
|
||||||
|
import android.app.Dialog;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
import android.preference.EditTextPreference;
|
import android.preference.EditTextPreference;
|
||||||
import android.preference.Preference;
|
import android.preference.Preference;
|
||||||
import android.text.InputType;
|
import android.text.InputType;
|
||||||
|
import android.text.TextUtils;
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Pair;
|
||||||
import android.util.TypedValue;
|
import android.util.TypedValue;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import app.revanced.extension.shared.settings.Setting;
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.drawable.ShapeDrawable;
|
||||||
|
import android.graphics.drawable.shapes.RoundRectShape;
|
||||||
import app.revanced.extension.shared.Logger;
|
import app.revanced.extension.shared.Logger;
|
||||||
import app.revanced.extension.shared.Utils;
|
import app.revanced.extension.shared.Utils;
|
||||||
|
import app.revanced.extension.shared.settings.Setting;
|
||||||
import static app.revanced.extension.shared.StringRef.str;
|
|
||||||
|
|
||||||
@SuppressWarnings({"unused", "deprecation"})
|
@SuppressWarnings({"unused", "deprecation"})
|
||||||
public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
|
public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
|
||||||
@@ -54,7 +65,8 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
|
|||||||
@Override
|
@Override
|
||||||
public boolean onPreferenceClick(Preference preference) {
|
public boolean onPreferenceClick(Preference preference) {
|
||||||
try {
|
try {
|
||||||
// Must set text before preparing dialog, otherwise text is non selectable if this preference is later reopened.
|
// Must set text before showing dialog,
|
||||||
|
// otherwise text is non-selectable if this preference is later reopened.
|
||||||
existingSettings = Setting.exportToJson(getContext());
|
existingSettings = Setting.exportToJson(getContext());
|
||||||
getEditText().setText(existingSettings);
|
getEditText().setText(existingSettings);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@@ -64,18 +76,32 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
|
protected void showDialog(Bundle state) {
|
||||||
try {
|
try {
|
||||||
Utils.setEditTextDialogTheme(builder);
|
Context context = getContext();
|
||||||
|
EditText editText = getEditText();
|
||||||
|
|
||||||
// Show the user the settings in JSON format.
|
// Create a custom dialog with the EditText.
|
||||||
builder.setNeutralButton(str("revanced_settings_import_copy"), (dialog, which) -> {
|
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
|
||||||
Utils.setClipboard(getEditText().getText().toString());
|
context,
|
||||||
}).setPositiveButton(str("revanced_settings_import"), (dialog, which) -> {
|
str("revanced_pref_import_export_title"), // Title.
|
||||||
importSettings(builder.getContext(), getEditText().getText().toString());
|
null, // No message (EditText replaces it).
|
||||||
});
|
editText, // Pass the EditText.
|
||||||
|
str("revanced_settings_import"), // OK button text.
|
||||||
|
() -> importSettings(context, editText.getText().toString()), // OK button action.
|
||||||
|
() -> {}, // Cancel button action (dismiss only).
|
||||||
|
str("revanced_settings_import_copy"), // Neutral button (Copy) text.
|
||||||
|
() -> {
|
||||||
|
// Neutral button (Copy) action. Show the user the settings in JSON format.
|
||||||
|
Utils.setClipboard(editText.getText());
|
||||||
|
},
|
||||||
|
true // Dismiss dialog when onNeutralClick.
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show the dialog.
|
||||||
|
dialogPair.first.show();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "onPrepareDialogBuilder failure", ex);
|
Logger.printException(() -> "showDialog failure", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +114,7 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
|
|||||||
|
|
||||||
final boolean rebootNeeded = Setting.importFromJSON(context, replacementSettings);
|
final boolean rebootNeeded = Setting.importFromJSON(context, replacementSettings);
|
||||||
if (rebootNeeded) {
|
if (rebootNeeded) {
|
||||||
AbstractPreferenceFragment.showRestartDialog(getContext());
|
AbstractPreferenceFragment.showRestartDialog(context);
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "importSettings failure", ex);
|
Logger.printException(() -> "importSettings failure", ex);
|
||||||
@@ -96,5 +122,4 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
|
|||||||
AbstractPreferenceFragment.settingImportInProgress = false;
|
AbstractPreferenceFragment.settingImportInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package app.revanced.extension.shared.settings.preference;
|
||||||
|
|
||||||
|
import static app.revanced.extension.shared.StringRef.str;
|
||||||
|
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
import app.revanced.extension.shared.settings.BaseSettings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages a buffer for storing debug logs from {@link Logger}.
|
||||||
|
* Stores just under 1MB of the most recent log data.
|
||||||
|
*
|
||||||
|
* All methods are thread-safe.
|
||||||
|
*/
|
||||||
|
public final class LogBufferManager {
|
||||||
|
/** Maximum byte size of all buffer entries. Must be less than Android's 1 MB Binder transaction limit. */
|
||||||
|
private static final int BUFFER_MAX_BYTES = 900_000;
|
||||||
|
/** Limit number of log lines. */
|
||||||
|
private static final int BUFFER_MAX_SIZE = 10_000;
|
||||||
|
|
||||||
|
private static final Deque<String> logBuffer = new ConcurrentLinkedDeque<>();
|
||||||
|
private static final AtomicInteger logBufferByteSize = new AtomicInteger();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a log message to the internal buffer if debugging is enabled.
|
||||||
|
* The buffer is limited to approximately {@link #BUFFER_MAX_BYTES} or {@link #BUFFER_MAX_SIZE}
|
||||||
|
* to prevent excessive memory usage.
|
||||||
|
*
|
||||||
|
* @param message The log message to append.
|
||||||
|
*/
|
||||||
|
public static void appendToLogBuffer(String message) {
|
||||||
|
Objects.requireNonNull(message);
|
||||||
|
|
||||||
|
// It's very important that no Settings are used in this method,
|
||||||
|
// as this code is used when a context is not set and thus referencing
|
||||||
|
// a setting will crash the app.
|
||||||
|
logBuffer.addLast(message);
|
||||||
|
int newSize = logBufferByteSize.addAndGet(message.length());
|
||||||
|
|
||||||
|
// Remove oldest entries if over the log size limits.
|
||||||
|
while (newSize > BUFFER_MAX_BYTES || logBuffer.size() > BUFFER_MAX_SIZE) {
|
||||||
|
String removed = logBuffer.pollFirst();
|
||||||
|
if (removed == null) {
|
||||||
|
// Thread race of two different calls to this method, and the other thread won.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
newSize = logBufferByteSize.addAndGet(-removed.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports all logs from the internal buffer to the clipboard.
|
||||||
|
* Displays a toast with the result.
|
||||||
|
*/
|
||||||
|
public static void exportToClipboard() {
|
||||||
|
try {
|
||||||
|
if (!BaseSettings.DEBUG.get()) {
|
||||||
|
Utils.showToastShort(str("revanced_debug_logs_disabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logBuffer.isEmpty()) {
|
||||||
|
Utils.showToastShort(str("revanced_debug_logs_none_found"));
|
||||||
|
clearLogBufferData(); // Clear toast log entry that was just created.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most (but not all) Android 13+ devices always show a "copied to clipboard" toast
|
||||||
|
// and there is no way to programmatically detect if a toast will show or not.
|
||||||
|
// Show a toast even if using Android 13+, but show ReVanced toast first (before copying to clipboard).
|
||||||
|
Utils.showToastShort(str("revanced_debug_logs_copied_to_clipboard"));
|
||||||
|
|
||||||
|
Utils.setClipboard(String.join("\n", logBuffer));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// Handle security exception if clipboard access is denied.
|
||||||
|
String errorMessage = String.format(str("revanced_debug_logs_failed_to_export"), ex.getMessage());
|
||||||
|
Utils.showToastLong(errorMessage);
|
||||||
|
Logger.printDebug(() -> errorMessage, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void clearLogBufferData() {
|
||||||
|
// Cannot simply clear the log buffer because there is no
|
||||||
|
// write lock for both the deque and the atomic int.
|
||||||
|
// Instead pop off log entries and decrement the size one by one.
|
||||||
|
while (!logBuffer.isEmpty()) {
|
||||||
|
String removed = logBuffer.pollFirst();
|
||||||
|
if (removed != null) {
|
||||||
|
logBufferByteSize.addAndGet(-removed.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the internal log buffer and displays a toast with the result.
|
||||||
|
*/
|
||||||
|
public static void clearLogBuffer() {
|
||||||
|
if (!BaseSettings.DEBUG.get()) {
|
||||||
|
Utils.showToastShort(str("revanced_debug_logs_disabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show toast before clearing, otherwise toast log will still remain.
|
||||||
|
Utils.showToastShort(str("revanced_debug_logs_clear_toast"));
|
||||||
|
clearLogBufferData();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package app.revanced.extension.shared.settings.preference;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.preference.PreferenceCategory;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty preference category with no title, used to organize and group related preferences together.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings({"unused", "deprecation"})
|
||||||
|
public class NoTitlePreferenceCategory extends PreferenceCategory {
|
||||||
|
|
||||||
|
public NoTitlePreferenceCategory(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NoTitlePreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NoTitlePreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||||
|
super(context, attrs, defStyleAttr, defStyleRes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NoTitlePreferenceCategory(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressLint("MissingSuperCall")
|
||||||
|
protected View onCreateView(ViewGroup parent) {
|
||||||
|
// Return an zero-height view to eliminate empty title space.
|
||||||
|
return new View(getContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharSequence getTitle() {
|
||||||
|
// Title can be used for sorting. Return the first sub preference title.
|
||||||
|
if (getPreferenceCount() > 0) {
|
||||||
|
return getPreference(0).getTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.getTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getTitleRes() {
|
||||||
|
if (getPreferenceCount() > 0) {
|
||||||
|
return getPreference(0).getTitleRes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.getTitleRes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package app.revanced.extension.shared.settings.preference;
|
package app.revanced.extension.shared.settings.preference;
|
||||||
|
|
||||||
import static app.revanced.extension.shared.StringRef.str;
|
import static app.revanced.extension.shared.StringRef.str;
|
||||||
|
import static app.revanced.extension.shared.Utils.dipToPixels;
|
||||||
import static app.revanced.extension.shared.requests.Route.Method.GET;
|
import static app.revanced.extension.shared.requests.Route.Method.GET;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
@@ -8,17 +9,19 @@ import android.app.Dialog;
|
|||||||
import android.app.ProgressDialog;
|
import android.app.ProgressDialog;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.res.Configuration;
|
import android.graphics.drawable.ShapeDrawable;
|
||||||
import android.graphics.Color;
|
import android.graphics.drawable.shapes.RoundRectShape;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.preference.Preference;
|
import android.preference.Preference;
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
import android.view.Window;
|
import android.view.Window;
|
||||||
import android.webkit.WebView;
|
import android.webkit.WebView;
|
||||||
import android.webkit.WebViewClient;
|
import android.webkit.WebViewClient;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
@@ -49,28 +52,6 @@ public class ReVancedAboutPreference extends Preference {
|
|||||||
return text.replace("-", "‑"); // #8209 = non breaking hyphen.
|
return text.replace("-", "‑"); // #8209 = non breaking hyphen.
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String getColorHexString(int color) {
|
|
||||||
return String.format("#%06X", (0x00FFFFFF & color));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected boolean isDarkModeEnabled() {
|
|
||||||
return Utils.isDarkModeEnabled(getContext());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subclasses can override this and provide a themed color.
|
|
||||||
*/
|
|
||||||
protected int getLightColor() {
|
|
||||||
return Color.WHITE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subclasses can override this and provide a themed color.
|
|
||||||
*/
|
|
||||||
protected int getDarkColor() {
|
|
||||||
return Color.BLACK;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apps that do not support bundling resources must override this.
|
* Apps that do not support bundling resources must override this.
|
||||||
*
|
*
|
||||||
@@ -87,9 +68,8 @@ public class ReVancedAboutPreference extends Preference {
|
|||||||
builder.append("<html>");
|
builder.append("<html>");
|
||||||
builder.append("<body style=\"text-align: center; padding: 10px;\">");
|
builder.append("<body style=\"text-align: center; padding: 10px;\">");
|
||||||
|
|
||||||
final boolean isDarkMode = isDarkModeEnabled();
|
String foregroundColorHex = Utils.getColorHexString(Utils.getAppForegroundColor());
|
||||||
String backgroundColorHex = getColorHexString(isDarkMode ? getDarkColor() : getLightColor());
|
String backgroundColorHex = Utils.getColorHexString(Utils.getDialogBackgroundColor());
|
||||||
String foregroundColorHex = getColorHexString(isDarkMode ? getLightColor() : getDarkColor());
|
|
||||||
// Apply light/dark mode colors.
|
// Apply light/dark mode colors.
|
||||||
builder.append(String.format(
|
builder.append(String.format(
|
||||||
"<style> body { background-color: %s; color: %s; } a { color: %s; } </style>",
|
"<style> body { background-color: %s; color: %s; } a { color: %s; } </style>",
|
||||||
@@ -221,14 +201,38 @@ class WebViewDialog extends Dialog {
|
|||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
requestWindowFeature(Window.FEATURE_NO_TITLE); // Remove default title bar.
|
||||||
|
|
||||||
|
// Create main layout.
|
||||||
|
LinearLayout mainLayout = new LinearLayout(getContext());
|
||||||
|
mainLayout.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
|
||||||
|
final int padding = dipToPixels(10);
|
||||||
|
mainLayout.setPadding(padding, padding, padding, padding);
|
||||||
|
// Set rounded rectangle background.
|
||||||
|
ShapeDrawable mainBackground = new ShapeDrawable(new RoundRectShape(
|
||||||
|
Utils.createCornerRadii(28), null, null));
|
||||||
|
mainBackground.getPaint().setColor(Utils.getDialogBackgroundColor());
|
||||||
|
mainLayout.setBackground(mainBackground);
|
||||||
|
|
||||||
|
// Create WebView.
|
||||||
WebView webView = new WebView(getContext());
|
WebView webView = new WebView(getContext());
|
||||||
|
webView.setVerticalScrollBarEnabled(false); // Disable the vertical scrollbar.
|
||||||
|
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||||
webView.getSettings().setJavaScriptEnabled(true);
|
webView.getSettings().setJavaScriptEnabled(true);
|
||||||
webView.setWebViewClient(new OpenLinksExternallyWebClient());
|
webView.setWebViewClient(new OpenLinksExternallyWebClient());
|
||||||
webView.loadDataWithBaseURL(null, htmlContent, "text/html", "utf-8", null);
|
webView.loadDataWithBaseURL(null, htmlContent, "text/html", "utf-8", null);
|
||||||
|
|
||||||
setContentView(webView);
|
// Add WebView to layout.
|
||||||
|
mainLayout.addView(webView);
|
||||||
|
|
||||||
|
setContentView(mainLayout);
|
||||||
|
|
||||||
|
// Set dialog window attributes
|
||||||
|
Window window = getWindow();
|
||||||
|
if (window != null) {
|
||||||
|
Utils.setDialogWindowParameters(window);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class OpenLinksExternallyWebClient extends WebViewClient {
|
private class OpenLinksExternallyWebClient extends WebViewClient {
|
||||||
@@ -316,7 +320,7 @@ class AboutLinksRoutes {
|
|||||||
// Do not show an exception toast if the server is down
|
// Do not show an exception toast if the server is down
|
||||||
final int responseCode = connection.getResponseCode();
|
final int responseCode = connection.getResponseCode();
|
||||||
if (responseCode != 200) {
|
if (responseCode != 200) {
|
||||||
Logger.printDebug(() -> "Failed to get social links. Response code: " + responseCode);
|
Logger.printDebug(() -> "Failed to get social links. Response code: " + responseCode);
|
||||||
return NO_CONNECTION_STATIC_LINKS;
|
return NO_CONNECTION_STATIC_LINKS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
package app.revanced.extension.shared.settings.preference;
|
package app.revanced.extension.shared.settings.preference;
|
||||||
|
|
||||||
import android.app.AlertDialog;
|
import static app.revanced.extension.shared.StringRef.str;
|
||||||
|
import static app.revanced.extension.shared.Utils.dipToPixels;
|
||||||
|
|
||||||
|
import android.app.Dialog;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
import android.graphics.drawable.LayerDrawable;
|
||||||
|
import android.graphics.drawable.shapes.RectShape;
|
||||||
|
import android.graphics.drawable.shapes.RoundRectShape;
|
||||||
|
import android.graphics.drawable.ShapeDrawable;
|
||||||
|
import android.graphics.Paint.Style;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.preference.EditTextPreference;
|
import android.preference.EditTextPreference;
|
||||||
|
import android.text.TextUtils;
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Pair;
|
||||||
|
import android.view.ViewGroup;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
import app.revanced.extension.shared.Utils;
|
import androidx.annotation.Nullable;
|
||||||
import app.revanced.extension.shared.settings.Setting;
|
|
||||||
import app.revanced.extension.shared.Logger;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
import static app.revanced.extension.shared.StringRef.str;
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
import app.revanced.extension.shared.settings.Setting;
|
||||||
|
|
||||||
@SuppressWarnings({"unused", "deprecation"})
|
@SuppressWarnings({"unused", "deprecation"})
|
||||||
public class ResettableEditTextPreference extends EditTextPreference {
|
public class ResettableEditTextPreference extends EditTextPreference {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setting to reset.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private Setting<?> setting;
|
||||||
|
|
||||||
public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||||
super(context, attrs, defStyleAttr, defStyleRes);
|
super(context, attrs, defStyleAttr, defStyleRes);
|
||||||
}
|
}
|
||||||
@@ -32,36 +54,65 @@ public class ResettableEditTextPreference extends EditTextPreference {
|
|||||||
super(context);
|
super(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public void setSetting(@Nullable Setting<?> setting) {
|
||||||
protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
|
this.setting = setting;
|
||||||
super.onPrepareDialogBuilder(builder);
|
|
||||||
Utils.setEditTextDialogTheme(builder);
|
|
||||||
|
|
||||||
Setting<?> setting = Setting.getSettingFromPath(getKey());
|
|
||||||
if (setting != null) {
|
|
||||||
builder.setNeutralButton(str("revanced_settings_reset"), null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void showDialog(Bundle state) {
|
protected void showDialog(Bundle state) {
|
||||||
super.showDialog(state);
|
try {
|
||||||
|
Context context = getContext();
|
||||||
|
EditText editText = getEditText();
|
||||||
|
|
||||||
// Override the button click listener to prevent dismissing the dialog.
|
// Resolve setting if not already set.
|
||||||
Button button = ((AlertDialog) getDialog()).getButton(AlertDialog.BUTTON_NEUTRAL);
|
if (setting == null) {
|
||||||
if (button == null) {
|
String key = getKey();
|
||||||
return;
|
if (key != null) {
|
||||||
}
|
setting = Setting.getSettingFromPath(key);
|
||||||
button.setOnClickListener(v -> {
|
}
|
||||||
try {
|
|
||||||
Setting<?> setting = Objects.requireNonNull(Setting.getSettingFromPath(getKey()));
|
|
||||||
String defaultStringValue = setting.defaultValue.toString();
|
|
||||||
EditText editText = getEditText();
|
|
||||||
editText.setText(defaultStringValue);
|
|
||||||
editText.setSelection(defaultStringValue.length()); // move cursor to end of text
|
|
||||||
} catch (Exception ex) {
|
|
||||||
Logger.printException(() -> "reset failure", ex);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Set initial EditText value to the current persisted value or empty string.
|
||||||
|
String initialValue = getText() != null ? getText() : "";
|
||||||
|
editText.setText(initialValue);
|
||||||
|
editText.setSelection(initialValue.length()); // Move cursor to end.
|
||||||
|
|
||||||
|
// Create custom dialog.
|
||||||
|
String neutralButtonText = (setting != null) ? str("revanced_settings_reset") : null;
|
||||||
|
Pair<Dialog, LinearLayout> dialogPair = Utils.createCustomDialog(
|
||||||
|
context,
|
||||||
|
getTitle() != null ? getTitle().toString() : "", // Title.
|
||||||
|
null, // Message is replaced by EditText.
|
||||||
|
editText, // Pass the EditText.
|
||||||
|
null, // OK button text.
|
||||||
|
() -> {
|
||||||
|
// OK button action. Persist the EditText value when OK is clicked.
|
||||||
|
String newValue = editText.getText().toString();
|
||||||
|
if (callChangeListener(newValue)) {
|
||||||
|
setText(newValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() -> {}, // Cancel button action (dismiss only).
|
||||||
|
neutralButtonText, // Neutral button text (Reset).
|
||||||
|
() -> {
|
||||||
|
// Neutral button action.
|
||||||
|
if (setting != null) {
|
||||||
|
try {
|
||||||
|
String defaultStringValue = Objects.requireNonNull(setting).defaultValue.toString();
|
||||||
|
editText.setText(defaultStringValue);
|
||||||
|
editText.setSelection(defaultStringValue.length()); // Move cursor to end of text.
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "reset failure", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false // Do not dismiss dialog when onNeutralClick.
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show the dialog.
|
||||||
|
dialogPair.first.show();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "showDialog failure", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package app.revanced.extension.shared.settings.preference;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.util.Pair;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PreferenceList that sorts itself.
|
||||||
|
* By default the first entry is preserved in its original position,
|
||||||
|
* and all other entries are sorted alphabetically.
|
||||||
|
*
|
||||||
|
* Ideally the 'keep first entries to preserve' is an xml parameter,
|
||||||
|
* but currently that's not so simple since Extensions code cannot use
|
||||||
|
* generated code from the Patches repo (which is required for custom xml parameters).
|
||||||
|
*
|
||||||
|
* If any class wants to use a different getFirstEntriesToPreserve value,
|
||||||
|
* it needs to subclass this preference and override {@link #getFirstEntriesToPreserve}.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings({"unused", "deprecation"})
|
||||||
|
public class SortedListPreference extends CustomDialogListPreference {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts the current list entries.
|
||||||
|
*
|
||||||
|
* @param firstEntriesToPreserve The number of entries to preserve in their original position,
|
||||||
|
* or a negative value to not sort and leave entries
|
||||||
|
* as they current are.
|
||||||
|
*/
|
||||||
|
public void sortEntryAndValues(int firstEntriesToPreserve) {
|
||||||
|
CharSequence[] entries = getEntries();
|
||||||
|
CharSequence[] entryValues = getEntryValues();
|
||||||
|
if (entries == null || entryValues == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int entrySize = entries.length;
|
||||||
|
if (entrySize != entryValues.length) {
|
||||||
|
// Xml array declaration has a missing/extra entry.
|
||||||
|
throw new IllegalStateException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstEntriesToPreserve < 0) {
|
||||||
|
return; // Nothing to do.
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Pair<CharSequence, CharSequence>> firstEntries = new ArrayList<>(firstEntriesToPreserve);
|
||||||
|
|
||||||
|
// Android does not have a triple class like Kotlin, So instead use a nested pair.
|
||||||
|
// Cannot easily use a SortedMap, because if two entries incorrectly have
|
||||||
|
// identical names then the duplicates entries are not preserved.
|
||||||
|
List<Pair<String, Pair<CharSequence, CharSequence>>> lastEntries = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < entrySize; i++) {
|
||||||
|
Pair<CharSequence, CharSequence> pair = new Pair<>(entries[i], entryValues[i]);
|
||||||
|
if (i < firstEntriesToPreserve) {
|
||||||
|
firstEntries.add(pair);
|
||||||
|
} else {
|
||||||
|
lastEntries.add(new Pair<>(Utils.removePunctuationToLowercase(pair.first), pair));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//noinspection ComparatorCombinators
|
||||||
|
Collections.sort(lastEntries, (pair1, pair2)
|
||||||
|
-> pair1.first.compareTo(pair2.first));
|
||||||
|
|
||||||
|
CharSequence[] sortedEntries = new CharSequence[entrySize];
|
||||||
|
CharSequence[] sortedEntryValues = new CharSequence[entrySize];
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
for (Pair<CharSequence, CharSequence> pair : firstEntries) {
|
||||||
|
sortedEntries[i] = pair.first;
|
||||||
|
sortedEntryValues[i] = pair.second;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Pair<String, Pair<CharSequence, CharSequence>> outer : lastEntries) {
|
||||||
|
Pair<CharSequence, CharSequence> inner = outer.second;
|
||||||
|
sortedEntries[i] = inner.first;
|
||||||
|
sortedEntryValues[i] = inner.second;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
super.setEntries(sortedEntries);
|
||||||
|
super.setEntryValues(sortedEntryValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SortedListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||||
|
super(context, attrs, defStyleAttr, defStyleRes);
|
||||||
|
|
||||||
|
sortEntryAndValues(getFirstEntriesToPreserve());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SortedListPreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
|
||||||
|
sortEntryAndValues(getFirstEntriesToPreserve());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SortedListPreference(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
|
||||||
|
sortEntryAndValues(getFirstEntriesToPreserve());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SortedListPreference(Context context) {
|
||||||
|
super(context);
|
||||||
|
|
||||||
|
sortEntryAndValues(getFirstEntriesToPreserve());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The number of first entries to leave exactly where they are, and do not sort them.
|
||||||
|
* A negative value indicates do not sort any entries.
|
||||||
|
*/
|
||||||
|
protected int getFirstEntriesToPreserve() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package app.revanced.extension.shared.spoof;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
public enum AudioStreamLanguage {
|
|
||||||
/**
|
|
||||||
* YouTube default.
|
|
||||||
* Can be the original language or can be app language,
|
|
||||||
* depending on what YouTube decides to pick as the default.
|
|
||||||
*/
|
|
||||||
DEFAULT,
|
|
||||||
|
|
||||||
// Language codes found in locale_config.xml
|
|
||||||
// Region specific variants of Chinese/English/Spanish/French have been removed.
|
|
||||||
AF,
|
|
||||||
AM,
|
|
||||||
AR,
|
|
||||||
AS,
|
|
||||||
AZ,
|
|
||||||
BE,
|
|
||||||
BG,
|
|
||||||
BN,
|
|
||||||
BS,
|
|
||||||
CA,
|
|
||||||
CS,
|
|
||||||
DA,
|
|
||||||
DE,
|
|
||||||
EL,
|
|
||||||
EN,
|
|
||||||
ES,
|
|
||||||
ET,
|
|
||||||
EU,
|
|
||||||
FA,
|
|
||||||
FI,
|
|
||||||
FR,
|
|
||||||
GL,
|
|
||||||
GU,
|
|
||||||
HI,
|
|
||||||
HE, // App uses obsolete 'IW' and 'HE' is modern ISO code.
|
|
||||||
HR,
|
|
||||||
HU,
|
|
||||||
HY,
|
|
||||||
ID,
|
|
||||||
IS,
|
|
||||||
IT,
|
|
||||||
JA,
|
|
||||||
KA,
|
|
||||||
KK,
|
|
||||||
KM,
|
|
||||||
KN,
|
|
||||||
KO,
|
|
||||||
KY,
|
|
||||||
LO,
|
|
||||||
LT,
|
|
||||||
LV,
|
|
||||||
MK,
|
|
||||||
ML,
|
|
||||||
MN,
|
|
||||||
MR,
|
|
||||||
MS,
|
|
||||||
MY,
|
|
||||||
NE,
|
|
||||||
NL,
|
|
||||||
NB,
|
|
||||||
OR,
|
|
||||||
PA,
|
|
||||||
PL,
|
|
||||||
PT_BR,
|
|
||||||
PT_PT,
|
|
||||||
RO,
|
|
||||||
RU,
|
|
||||||
SI,
|
|
||||||
SK,
|
|
||||||
SL,
|
|
||||||
SQ,
|
|
||||||
SR,
|
|
||||||
SV,
|
|
||||||
SW,
|
|
||||||
TA,
|
|
||||||
TE,
|
|
||||||
TH,
|
|
||||||
TL,
|
|
||||||
TR,
|
|
||||||
UK,
|
|
||||||
UR,
|
|
||||||
UZ,
|
|
||||||
VI,
|
|
||||||
ZH,
|
|
||||||
ZU;
|
|
||||||
|
|
||||||
private final String iso639_1;
|
|
||||||
|
|
||||||
AudioStreamLanguage() {
|
|
||||||
String name = name();
|
|
||||||
final int regionSeparatorIndex = name.indexOf('_');
|
|
||||||
if (regionSeparatorIndex >= 0) {
|
|
||||||
iso639_1 = name.substring(0, regionSeparatorIndex).toLowerCase(Locale.US)
|
|
||||||
+ name.substring(regionSeparatorIndex);
|
|
||||||
} else {
|
|
||||||
iso639_1 = name().toLowerCase(Locale.US);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getIso639_1() {
|
|
||||||
// Changing the app language does not force the app to completely restart,
|
|
||||||
// so the default needs to be the current language and not a static field.
|
|
||||||
if (this == DEFAULT) {
|
|
||||||
return Locale.getDefault().toLanguageTag();
|
|
||||||
}
|
|
||||||
|
|
||||||
return iso639_1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,43 +4,114 @@ import android.os.Build;
|
|||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
import app.revanced.extension.shared.settings.BaseSettings;
|
import app.revanced.extension.shared.settings.BaseSettings;
|
||||||
|
|
||||||
public enum ClientType {
|
public enum ClientType {
|
||||||
// Specific purpose for age restricted, or private videos, because the iOS client is not logged in.
|
|
||||||
// https://dumps.tadiphone.dev/dumps/oculus/eureka
|
// https://dumps.tadiphone.dev/dumps/oculus/eureka
|
||||||
ANDROID_VR(28,
|
ANDROID_VR_NO_AUTH(
|
||||||
|
28,
|
||||||
"ANDROID_VR",
|
"ANDROID_VR",
|
||||||
|
"com.google.android.apps.youtube.vr.oculus",
|
||||||
|
"Oculus",
|
||||||
"Quest 3",
|
"Quest 3",
|
||||||
|
"Android",
|
||||||
"12",
|
"12",
|
||||||
"com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
|
// Android 12.1
|
||||||
"32", // Android 12.1
|
"32",
|
||||||
"1.56.21",
|
"SQ3A.220605.009.A1",
|
||||||
|
"132.0.6808.3",
|
||||||
|
"1.61.48",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"Android VR No auth"
|
||||||
|
),
|
||||||
|
// Chromecast with Google TV 4K.
|
||||||
|
// https://dumps.tadiphone.dev/dumps/google/kirkwood
|
||||||
|
ANDROID_UNPLUGGED(
|
||||||
|
29,
|
||||||
|
"ANDROID_UNPLUGGED",
|
||||||
|
"com.google.android.apps.youtube.unplugged",
|
||||||
|
"Google",
|
||||||
|
"Google TV Streamer",
|
||||||
|
"Android",
|
||||||
|
"14",
|
||||||
|
"34",
|
||||||
|
"UTT3.240625.001.K5",
|
||||||
|
"132.0.6808.3",
|
||||||
|
"8.49.0",
|
||||||
true,
|
true,
|
||||||
false),
|
true,
|
||||||
// Specific for kids videos.
|
"Android TV"
|
||||||
IOS(5,
|
),
|
||||||
"IOS",
|
// Cannot play livestreams and lacks HDR, but can play videos with music and labeled "for children".
|
||||||
|
// Google Pixel 9 Pro Fold
|
||||||
|
// https://dumps.tadiphone.dev/dumps/google/barbet
|
||||||
|
ANDROID_CREATOR(
|
||||||
|
14,
|
||||||
|
"ANDROID_CREATOR",
|
||||||
|
"com.google.android.apps.youtube.creator",
|
||||||
|
"Google",
|
||||||
|
"Pixel 9 Pro Fold",
|
||||||
|
"Android",
|
||||||
|
"15",
|
||||||
|
"35",
|
||||||
|
"AP3A.241005.015.A2",
|
||||||
|
"132.0.6779.0",
|
||||||
|
"23.47.101",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
"Android Creator"
|
||||||
|
),
|
||||||
|
IOS_UNPLUGGED(
|
||||||
|
33,
|
||||||
|
"IOS_UNPLUGGED",
|
||||||
|
"com.google.ios.youtubeunplugged",
|
||||||
|
"Apple",
|
||||||
forceAVC()
|
forceAVC()
|
||||||
? "iPhone12,5" // 11 Pro Max (last device with iOS 13)
|
// 11 Pro Max (last device with iOS 13)
|
||||||
: "iPhone16,2", // 15 Pro Max
|
? "iPhone12,5"
|
||||||
// iOS 13 and earlier uses only AVC. 14+ adds VP9 and AV1.
|
// 15 Pro Max
|
||||||
|
: "iPhone16,2",
|
||||||
|
"iOS",
|
||||||
forceAVC()
|
forceAVC()
|
||||||
? "13.7.17H35" // Last release of iOS 13.
|
// iOS 13 and earlier uses only AVC. 14+ adds VP9 and AV1.
|
||||||
: "17.5.1.21F90",
|
? "13.7.17H35"
|
||||||
forceAVC()
|
: "18.2.22C152",
|
||||||
? "com.google.ios.youtube/17.40.5 (iPhone; U; CPU iOS 13_7 like Mac OS X)"
|
null,
|
||||||
: "com.google.ios.youtube/19.47.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)",
|
null,
|
||||||
null,
|
null,
|
||||||
// Version number should be a valid iOS release.
|
// Version number should be a valid iOS release.
|
||||||
// https://www.ipa4fun.com/history/185230
|
// https://www.ipa4fun.com/history/152043/
|
||||||
forceAVC()
|
forceAVC()
|
||||||
// Some newer versions can also force AVC,
|
// Some newer versions can also force AVC,
|
||||||
// but 17.40 is the last version that supports iOS 13.
|
// but 6.45 is the last version that supports iOS 13.
|
||||||
? "17.40.5"
|
? "6.45"
|
||||||
: "19.47.7",
|
: "8.49",
|
||||||
false,
|
true,
|
||||||
true
|
true,
|
||||||
|
forceAVC()
|
||||||
|
? "iOS TV Force AVC"
|
||||||
|
: "iOS TV"
|
||||||
|
),
|
||||||
|
ANDROID_VR_AUTH(
|
||||||
|
ANDROID_VR_NO_AUTH.id,
|
||||||
|
ANDROID_VR_NO_AUTH.clientName,
|
||||||
|
ANDROID_VR_NO_AUTH.packageName,
|
||||||
|
ANDROID_VR_NO_AUTH.deviceMake,
|
||||||
|
ANDROID_VR_NO_AUTH.deviceModel,
|
||||||
|
ANDROID_VR_NO_AUTH.osName,
|
||||||
|
ANDROID_VR_NO_AUTH.osVersion,
|
||||||
|
ANDROID_VR_NO_AUTH.androidSdkVersion,
|
||||||
|
ANDROID_VR_NO_AUTH.buildId,
|
||||||
|
ANDROID_VR_NO_AUTH.cronetVersion,
|
||||||
|
ANDROID_VR_NO_AUTH.clientVersion,
|
||||||
|
ANDROID_VR_NO_AUTH.requiresAuth,
|
||||||
|
true,
|
||||||
|
"Android VR Auth"
|
||||||
);
|
);
|
||||||
|
|
||||||
private static boolean forceAVC() {
|
private static boolean forceAVC() {
|
||||||
@@ -56,20 +127,35 @@ public enum ClientType {
|
|||||||
public final String clientName;
|
public final String clientName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
|
* App package name.
|
||||||
*/
|
*/
|
||||||
public final String deviceModel;
|
private final String packageName;
|
||||||
|
|
||||||
/**
|
|
||||||
* Device OS version.
|
|
||||||
*/
|
|
||||||
public final String osVersion;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Player user-agent.
|
* Player user-agent.
|
||||||
*/
|
*/
|
||||||
public final String userAgent;
|
public final String userAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device model, equivalent to {@link Build#MANUFACTURER} (System property: ro.product.vendor.manufacturer)
|
||||||
|
*/
|
||||||
|
public final String deviceMake;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.vendor.model)
|
||||||
|
*/
|
||||||
|
public final String deviceModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device OS name.
|
||||||
|
*/
|
||||||
|
public final String osName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device OS version.
|
||||||
|
*/
|
||||||
|
public final String osVersion;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk)
|
* Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk)
|
||||||
* Field is null if not applicable.
|
* Field is null if not applicable.
|
||||||
@@ -77,38 +163,97 @@ public enum ClientType {
|
|||||||
@Nullable
|
@Nullable
|
||||||
public final String androidSdkVersion;
|
public final String androidSdkVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android build id, equivalent to {@link Build#ID}.
|
||||||
|
* Field is null if not applicable.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private final String buildId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cronet release version, as found in decompiled client apk.
|
||||||
|
* Field is null if not applicable.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private final String cronetVersion;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App version.
|
* App version.
|
||||||
*/
|
*/
|
||||||
public final String clientVersion;
|
public final String clientVersion;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the client can access the API logged in.
|
* If this client requires authentication and does not work
|
||||||
|
* if logged out or in incognito mode.
|
||||||
*/
|
*/
|
||||||
public final boolean canLogin;
|
public final boolean requiresAuth;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If a language code should be used.
|
* If the client should use authentication if available.
|
||||||
*/
|
*/
|
||||||
public final boolean useLanguageCode;
|
public final boolean useAuth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Friendly name displayed in stats for nerds.
|
||||||
|
*/
|
||||||
|
public final String friendlyName;
|
||||||
|
|
||||||
|
@SuppressWarnings("ConstantLocale")
|
||||||
ClientType(int id,
|
ClientType(int id,
|
||||||
String clientName,
|
String clientName,
|
||||||
|
String packageName,
|
||||||
|
String deviceMake,
|
||||||
String deviceModel,
|
String deviceModel,
|
||||||
|
String osName,
|
||||||
String osVersion,
|
String osVersion,
|
||||||
String userAgent,
|
|
||||||
@Nullable String androidSdkVersion,
|
@Nullable String androidSdkVersion,
|
||||||
|
@Nullable String buildId,
|
||||||
|
@Nullable String cronetVersion,
|
||||||
String clientVersion,
|
String clientVersion,
|
||||||
boolean canLogin,
|
boolean requiresAuth,
|
||||||
boolean useLanguageCode) {
|
boolean useAuth,
|
||||||
|
String friendlyName) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.clientName = clientName;
|
this.clientName = clientName;
|
||||||
|
this.packageName = packageName;
|
||||||
|
this.deviceMake = deviceMake;
|
||||||
this.deviceModel = deviceModel;
|
this.deviceModel = deviceModel;
|
||||||
|
this.osName = osName;
|
||||||
this.osVersion = osVersion;
|
this.osVersion = osVersion;
|
||||||
this.userAgent = userAgent;
|
|
||||||
this.androidSdkVersion = androidSdkVersion;
|
this.androidSdkVersion = androidSdkVersion;
|
||||||
|
this.buildId = buildId;
|
||||||
|
this.cronetVersion = cronetVersion;
|
||||||
this.clientVersion = clientVersion;
|
this.clientVersion = clientVersion;
|
||||||
this.canLogin = canLogin;
|
this.requiresAuth = requiresAuth;
|
||||||
this.useLanguageCode = useLanguageCode;
|
this.useAuth = useAuth;
|
||||||
|
this.friendlyName = friendlyName;
|
||||||
|
|
||||||
|
Locale defaultLocale = Locale.getDefault();
|
||||||
|
if (androidSdkVersion == null) {
|
||||||
|
// Convert version from '18.2.22C152' into '18_2_22'
|
||||||
|
String userAgentOsVersion = osVersion
|
||||||
|
.replaceAll("(\\d+\\.\\d+\\.\\d+).*", "$1")
|
||||||
|
.replace(".", "_");
|
||||||
|
// https://github.com/mitmproxy/mitmproxy/issues/4836
|
||||||
|
this.userAgent = String.format("%s/%s (%s; U; CPU iOS %s like Mac OS X; %s)",
|
||||||
|
packageName,
|
||||||
|
clientVersion,
|
||||||
|
deviceModel,
|
||||||
|
userAgentOsVersion,
|
||||||
|
defaultLocale
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.userAgent = String.format("%s/%s (Linux; U; Android %s; %s; %s; Build/%s; Cronet/%s)",
|
||||||
|
packageName,
|
||||||
|
clientVersion,
|
||||||
|
osVersion,
|
||||||
|
defaultLocale,
|
||||||
|
deviceModel,
|
||||||
|
Objects.requireNonNull(buildId),
|
||||||
|
Objects.requireNonNull(cronetVersion)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Logger.printDebug(() -> "userAgent: " + this.userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app.revanced.extension.shared.spoof;
|
package app.revanced.extension.shared.spoof;
|
||||||
|
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ public class SpoofVideoStreamsPatch {
|
|||||||
private static final boolean SPOOF_STREAMING_DATA = BaseSettings.SPOOF_VIDEO_STREAMS.get();
|
private static final boolean SPOOF_STREAMING_DATA = BaseSettings.SPOOF_VIDEO_STREAMS.get();
|
||||||
|
|
||||||
private static final boolean FIX_HLS_CURRENT_TIME = SPOOF_STREAMING_DATA
|
private static final boolean FIX_HLS_CURRENT_TIME = SPOOF_STREAMING_DATA
|
||||||
&& BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS;
|
&& BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS_UNPLUGGED;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Any unreachable ip address. Used to intentionally fail requests.
|
* Any unreachable ip address. Used to intentionally fail requests.
|
||||||
@@ -26,6 +27,19 @@ public class SpoofVideoStreamsPatch {
|
|||||||
private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0";
|
private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0";
|
||||||
private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING);
|
private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return If this patch was included during patching.
|
||||||
|
*/
|
||||||
|
private static boolean isPatchIncluded() {
|
||||||
|
return false; // Modified during patching.
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean notSpoofingToAndroid() {
|
||||||
|
return !isPatchIncluded()
|
||||||
|
|| !BaseSettings.SPOOF_VIDEO_STREAMS.get()
|
||||||
|
|| BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS_UNPLUGGED;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injection point.
|
* Injection point.
|
||||||
* Blocks /get_watch requests by returning an unreachable URI.
|
* Blocks /get_watch requests by returning an unreachable URI.
|
||||||
@@ -63,9 +77,9 @@ public class SpoofVideoStreamsPatch {
|
|||||||
String path = originalUri.getPath();
|
String path = originalUri.getPath();
|
||||||
|
|
||||||
if (path != null && path.contains("initplayback")) {
|
if (path != null && path.contains("initplayback")) {
|
||||||
Logger.printDebug(() -> "Blocking 'initplayback' by returning unreachable url");
|
Logger.printDebug(() -> "Blocking 'initplayback' by clearing query");
|
||||||
|
|
||||||
return UNREACHABLE_HOST_URI_STRING;
|
return originalUri.buildUpon().clearQuery().build().toString();
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
|
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
|
||||||
@@ -82,6 +96,47 @@ public class SpoofVideoStreamsPatch {
|
|||||||
return SPOOF_STREAMING_DATA;
|
return SPOOF_STREAMING_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point.
|
||||||
|
* Only invoked when playing a livestream on an iOS client.
|
||||||
|
*/
|
||||||
|
public static boolean fixHLSCurrentTime(boolean original) {
|
||||||
|
if (!SPOOF_STREAMING_DATA) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point.
|
||||||
|
* Turns off a feature flag that interferes with spoofing.
|
||||||
|
*/
|
||||||
|
public static boolean useMediaFetchHotConfigReplacement(boolean original) {
|
||||||
|
if (original) {
|
||||||
|
Logger.printDebug(() -> "useMediaFetchHotConfigReplacement is set on");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SPOOF_STREAMING_DATA) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point.
|
||||||
|
* Turns off a feature flag that interferes with video playback.
|
||||||
|
*/
|
||||||
|
public static boolean usePlaybackStartFeatureFlag(boolean original) {
|
||||||
|
if (original) {
|
||||||
|
Logger.printDebug(() -> "usePlaybackStartFeatureFlag is set on");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SPOOF_STREAMING_DATA) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injection point.
|
* Injection point.
|
||||||
*/
|
*/
|
||||||
@@ -90,20 +145,27 @@ public class SpoofVideoStreamsPatch {
|
|||||||
try {
|
try {
|
||||||
Uri uri = Uri.parse(url);
|
Uri uri = Uri.parse(url);
|
||||||
String path = uri.getPath();
|
String path = uri.getPath();
|
||||||
|
if (path == null || !path.contains("player")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'get_drm_license' has no video id and appears to happen when waiting for a paid video to start.
|
||||||
// 'heartbeat' has no video id and appears to be only after playback has started.
|
// 'heartbeat' has no video id and appears to be only after playback has started.
|
||||||
// 'refresh' has no video id and appears to happen when waiting for a livestream to start.
|
// 'refresh' has no video id and appears to happen when waiting for a livestream to start.
|
||||||
if (path != null && path.contains("player") && !path.contains("heartbeat")
|
// 'ad_break' has no video id.
|
||||||
&& !path.contains("refresh")) {
|
if (path.contains("get_drm_license") || path.contains("heartbeat")
|
||||||
String id = uri.getQueryParameter("id");
|
|| path.contains("refresh") || path.contains("ad_break")) {
|
||||||
if (id == null) {
|
Logger.printDebug(() -> "Ignoring path: " + path);
|
||||||
Logger.printException(() -> "Ignoring request that has no video id." +
|
return;
|
||||||
" Url: " + url + " headers: " + requestHeaders);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
StreamingDataRequest.fetchRequest(id, requestHeaders);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String id = uri.getQueryParameter("id");
|
||||||
|
if (id == null) {
|
||||||
|
Logger.printException(() -> "Ignoring request with no id: " + url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamingDataRequest.fetchRequest(id, requestHeaders);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "buildRequest failure", ex);
|
Logger.printException(() -> "buildRequest failure", ex);
|
||||||
}
|
}
|
||||||
@@ -171,22 +233,35 @@ public class SpoofVideoStreamsPatch {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Injection point.
|
* Injection point.
|
||||||
*
|
|
||||||
* Fixes iOS livestreams starting from the beginning.
|
|
||||||
*/
|
*/
|
||||||
public static boolean fixHLSCurrentTime(boolean original) {
|
public static String appendSpoofedClient(String videoFormat) {
|
||||||
if (FIX_HLS_CURRENT_TIME) {
|
try {
|
||||||
return false;
|
if (SPOOF_STREAMING_DATA && BaseSettings.SPOOF_STREAMING_DATA_STATS_FOR_NERDS.get()
|
||||||
|
&& !TextUtils.isEmpty(videoFormat)) {
|
||||||
|
// Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages.
|
||||||
|
return "\u202D" + videoFormat + "\u2009(" // u202D = left to right override
|
||||||
|
+ StreamingDataRequest.getLastSpoofedClientName() + ")";
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "appendSpoofedClient failure", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
return original;
|
return videoFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class AudioStreamLanguageOverrideAvailability implements Setting.Availability {
|
||||||
|
@Override
|
||||||
|
public boolean isAvailable() {
|
||||||
|
return BaseSettings.SPOOF_VIDEO_STREAMS.get()
|
||||||
|
&& BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.ANDROID_VR_NO_AUTH;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static final class SpoofiOSAvailability implements Setting.Availability {
|
public static final class SpoofiOSAvailability implements Setting.Availability {
|
||||||
@Override
|
@Override
|
||||||
public boolean isAvailable() {
|
public boolean isAvailable() {
|
||||||
return BaseSettings.SPOOF_VIDEO_STREAMS.get()
|
return BaseSettings.SPOOF_VIDEO_STREAMS.get()
|
||||||
&& BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS;
|
&& BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS_UNPLUGGED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.json.JSONObject;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
import app.revanced.extension.shared.Logger;
|
import app.revanced.extension.shared.Logger;
|
||||||
import app.revanced.extension.shared.requests.Requester;
|
import app.revanced.extension.shared.requests.Requester;
|
||||||
@@ -30,29 +31,39 @@ final class PlayerRoutes {
|
|||||||
private PlayerRoutes() {
|
private PlayerRoutes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static String createInnertubeBody(ClientType clientType) {
|
static String createInnertubeBody(ClientType clientType, String videoId) {
|
||||||
JSONObject innerTubeBody = new JSONObject();
|
JSONObject innerTubeBody = new JSONObject();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
JSONObject context = new JSONObject();
|
JSONObject context = new JSONObject();
|
||||||
|
|
||||||
|
// Can override default language only if no login is used.
|
||||||
|
// Could use preferred audio for all clients that do not login,
|
||||||
|
// but if this is a fall over client it will set the language even though
|
||||||
|
// the audio language is not selectable in the UI.
|
||||||
|
ClientType userSelectedClient = BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
|
||||||
|
Locale streamLocale = userSelectedClient == ClientType.ANDROID_VR_NO_AUTH
|
||||||
|
? BaseSettings.SPOOF_VIDEO_STREAMS_LANGUAGE.get().getLocale()
|
||||||
|
: Locale.getDefault();
|
||||||
|
|
||||||
JSONObject client = new JSONObject();
|
JSONObject client = new JSONObject();
|
||||||
if (clientType.useLanguageCode) {
|
client.put("deviceMake", clientType.deviceMake);
|
||||||
client.put("hl", BaseSettings.SPOOF_VIDEO_STREAMS_LANGUAGE.get().getIso639_1());
|
client.put("deviceModel", clientType.deviceModel);
|
||||||
}
|
|
||||||
client.put("clientName", clientType.clientName);
|
client.put("clientName", clientType.clientName);
|
||||||
client.put("clientVersion", clientType.clientVersion);
|
client.put("clientVersion", clientType.clientVersion);
|
||||||
client.put("deviceModel", clientType.deviceModel);
|
client.put("osName", clientType.osName);
|
||||||
client.put("osVersion", clientType.osVersion);
|
client.put("osVersion", clientType.osVersion);
|
||||||
if (clientType.androidSdkVersion != null) {
|
if (clientType.androidSdkVersion != null) {
|
||||||
client.put("androidSdkVersion", clientType.androidSdkVersion);
|
client.put("androidSdkVersion", clientType.androidSdkVersion);
|
||||||
}
|
}
|
||||||
|
client.put("hl", streamLocale.getLanguage());
|
||||||
|
client.put("gl", streamLocale.getCountry());
|
||||||
context.put("client", client);
|
context.put("client", client);
|
||||||
|
|
||||||
innerTubeBody.put("context", context);
|
innerTubeBody.put("context", context);
|
||||||
innerTubeBody.put("contentCheckOk", true);
|
innerTubeBody.put("contentCheckOk", true);
|
||||||
innerTubeBody.put("racyCheckOk", true);
|
innerTubeBody.put("racyCheckOk", true);
|
||||||
innerTubeBody.put("videoId", "%s");
|
innerTubeBody.put("videoId", videoId);
|
||||||
} catch (JSONException e) {
|
} catch (JSONException e) {
|
||||||
Logger.printException(() -> "Failed to create innerTubeBody", e);
|
Logger.printException(() -> "Failed to create innerTubeBody", e);
|
||||||
}
|
}
|
||||||
@@ -60,14 +71,15 @@ final class PlayerRoutes {
|
|||||||
return innerTubeBody.toString();
|
return innerTubeBody.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@SuppressWarnings("SameParameterValue")
|
||||||
* @noinspection SameParameterValue
|
|
||||||
*/
|
|
||||||
static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
|
static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
|
||||||
var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route);
|
var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route);
|
||||||
|
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setRequestProperty("User-Agent", clientType.userAgent);
|
connection.setRequestProperty("User-Agent", clientType.userAgent);
|
||||||
|
// Not a typo. "Client-Name" uses the client type id.
|
||||||
|
connection.setRequestProperty("X-YouTube-Client-Name", String.valueOf(clientType.id));
|
||||||
|
connection.setRequestProperty("X-YouTube-Client-Version", clientType.clientVersion);
|
||||||
|
|
||||||
connection.setUseCaches(false);
|
connection.setUseCaches(false);
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
|
|||||||
@@ -36,20 +36,40 @@ import app.revanced.extension.shared.spoof.ClientType;
|
|||||||
public class StreamingDataRequest {
|
public class StreamingDataRequest {
|
||||||
|
|
||||||
private static final ClientType[] CLIENT_ORDER_TO_USE;
|
private static final ClientType[] CLIENT_ORDER_TO_USE;
|
||||||
|
|
||||||
|
static {
|
||||||
|
ClientType[] allClientTypes = ClientType.values();
|
||||||
|
ClientType preferredClient = BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
|
||||||
|
|
||||||
|
CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length];
|
||||||
|
CLIENT_ORDER_TO_USE[0] = preferredClient;
|
||||||
|
|
||||||
|
int i = 1;
|
||||||
|
for (ClientType c : allClientTypes) {
|
||||||
|
if (c != preferredClient) {
|
||||||
|
CLIENT_ORDER_TO_USE[i++] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||||
|
|
||||||
private static final String[] REQUEST_HEADER_KEYS = {
|
private static final String[] REQUEST_HEADER_KEYS = {
|
||||||
AUTHORIZATION_HEADER, // Available only to logged-in users.
|
AUTHORIZATION_HEADER, // Available only to logged-in users.
|
||||||
"X-GOOG-API-FORMAT-VERSION",
|
"X-GOOG-API-FORMAT-VERSION",
|
||||||
"X-Goog-Visitor-Id"
|
"X-Goog-Visitor-Id"
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TCP connection and HTTP read timeout.
|
* TCP connection and HTTP read timeout.
|
||||||
*/
|
*/
|
||||||
private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000;
|
private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS}
|
* Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS}
|
||||||
*/
|
*/
|
||||||
private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000;
|
private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000;
|
||||||
|
|
||||||
private static final Map<String, StreamingDataRequest> cache = Collections.synchronizedMap(
|
private static final Map<String, StreamingDataRequest> cache = Collections.synchronizedMap(
|
||||||
new LinkedHashMap<>(100) {
|
new LinkedHashMap<>(100) {
|
||||||
/**
|
/**
|
||||||
@@ -67,22 +87,15 @@ public class StreamingDataRequest {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
static {
|
private static volatile ClientType lastSpoofedClientType;
|
||||||
ClientType[] allClientTypes = ClientType.values();
|
|
||||||
ClientType preferredClient = BaseSettings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
|
|
||||||
|
|
||||||
CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length];
|
public static String getLastSpoofedClientName() {
|
||||||
CLIENT_ORDER_TO_USE[0] = preferredClient;
|
ClientType client = lastSpoofedClientType;
|
||||||
|
return client == null ? "Unknown" : client.friendlyName;
|
||||||
int i = 1;
|
|
||||||
for (ClientType c : allClientTypes) {
|
|
||||||
if (c != preferredClient) {
|
|
||||||
CLIENT_ORDER_TO_USE[i++] = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private final String videoId;
|
private final String videoId;
|
||||||
|
|
||||||
private final Future<ByteBuffer> future;
|
private final Future<ByteBuffer> future;
|
||||||
|
|
||||||
private StreamingDataRequest(String videoId, Map<String, String> playerHeaders) {
|
private StreamingDataRequest(String videoId, Map<String, String> playerHeaders) {
|
||||||
@@ -107,7 +120,8 @@ public class StreamingDataRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private static HttpURLConnection send(ClientType clientType, String videoId,
|
private static HttpURLConnection send(ClientType clientType,
|
||||||
|
String videoId,
|
||||||
Map<String, String> playerHeaders,
|
Map<String, String> playerHeaders,
|
||||||
boolean showErrorToasts) {
|
boolean showErrorToasts) {
|
||||||
Objects.requireNonNull(clientType);
|
Objects.requireNonNull(clientType);
|
||||||
@@ -115,21 +129,24 @@ public class StreamingDataRequest {
|
|||||||
Objects.requireNonNull(playerHeaders);
|
Objects.requireNonNull(playerHeaders);
|
||||||
|
|
||||||
final long startTime = System.currentTimeMillis();
|
final long startTime = System.currentTimeMillis();
|
||||||
Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType);
|
HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType);
|
||||||
connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
||||||
connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
||||||
|
|
||||||
|
boolean authHeadersIncludes = false;
|
||||||
|
|
||||||
for (String key : REQUEST_HEADER_KEYS) {
|
for (String key : REQUEST_HEADER_KEYS) {
|
||||||
String value = playerHeaders.get(key);
|
String value = playerHeaders.get(key);
|
||||||
|
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
if (key.equals(AUTHORIZATION_HEADER)) {
|
if (key.equals(AUTHORIZATION_HEADER)) {
|
||||||
if (!clientType.canLogin) {
|
if (!clientType.useAuth) {
|
||||||
Logger.printDebug(() -> "Not including request header: " + key);
|
Logger.printDebug(() -> "Not including request header: " + key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
authHeadersIncludes = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.printDebug(() -> "Including request header: " + key);
|
Logger.printDebug(() -> "Including request header: " + key);
|
||||||
@@ -137,7 +154,15 @@ public class StreamingDataRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String innerTubeBody = String.format(PlayerRoutes.createInnertubeBody(clientType), videoId);
|
if (!authHeadersIncludes && clientType.requiresAuth) {
|
||||||
|
Logger.printDebug(() -> "Skipping client since user is not logged in: " + clientType
|
||||||
|
+ " videoId: " + videoId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType);
|
||||||
|
|
||||||
|
String innerTubeBody = PlayerRoutes.createInnertubeBody(clientType, videoId);
|
||||||
byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8);
|
byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8);
|
||||||
connection.setFixedLengthStreamingMode(requestBody.length);
|
connection.setFixedLengthStreamingMode(requestBody.length);
|
||||||
connection.getOutputStream().write(requestBody);
|
connection.getOutputStream().write(requestBody);
|
||||||
@@ -169,7 +194,7 @@ public class StreamingDataRequest {
|
|||||||
// Retry with different client if empty response body is received.
|
// Retry with different client if empty response body is received.
|
||||||
int i = 0;
|
int i = 0;
|
||||||
for (ClientType clientType : CLIENT_ORDER_TO_USE) {
|
for (ClientType clientType : CLIENT_ORDER_TO_USE) {
|
||||||
// Show an error if the last client type fails, or if the debug is enabled then show for all attempts.
|
// Show an error if the last client type fails, or if debug is enabled then show for all attempts.
|
||||||
final boolean showErrorToast = (++i == CLIENT_ORDER_TO_USE.length) || debugEnabled;
|
final boolean showErrorToast = (++i == CLIENT_ORDER_TO_USE.length) || debugEnabled;
|
||||||
|
|
||||||
HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast);
|
HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast);
|
||||||
@@ -178,7 +203,9 @@ public class StreamingDataRequest {
|
|||||||
// gzip encoding doesn't response with content length (-1),
|
// gzip encoding doesn't response with content length (-1),
|
||||||
// but empty response body does.
|
// but empty response body does.
|
||||||
if (connection.getContentLength() == 0) {
|
if (connection.getContentLength() == 0) {
|
||||||
Logger.printDebug(() -> "Received empty response for video: " + videoId);
|
if (BaseSettings.DEBUG.get() && BaseSettings.DEBUG_TOAST_ON_ERROR.get()) {
|
||||||
|
Utils.showToastShort("Debug: Ignoring empty spoof stream client " + clientType);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
try (InputStream inputStream = new BufferedInputStream(connection.getInputStream());
|
try (InputStream inputStream = new BufferedInputStream(connection.getInputStream());
|
||||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||||
@@ -188,6 +215,7 @@ public class StreamingDataRequest {
|
|||||||
while ((bytesRead = inputStream.read(buffer)) >= 0) {
|
while ((bytesRead = inputStream.read(buffer)) >= 0) {
|
||||||
baos.write(buffer, 0, bytesRead);
|
baos.write(buffer, 0, bytesRead);
|
||||||
}
|
}
|
||||||
|
lastSpoofedClientType = clientType;
|
||||||
|
|
||||||
return ByteBuffer.wrap(baos.toByteArray());
|
return ByteBuffer.wrap(baos.toByteArray());
|
||||||
}
|
}
|
||||||
@@ -198,7 +226,8 @@ public class StreamingDataRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnectionError("Could not fetch any client streams", null, debugEnabled);
|
lastSpoofedClientType = null;
|
||||||
|
handleConnectionError("Could not fetch any client streams", null, true);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
extensions/spotify/build.gradle.kts
Normal file
40
extensions/spotify/build.gradle.kts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.protobuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(project(":extensions:shared:library"))
|
||||||
|
compileOnly(project(":extensions:spotify:stub"))
|
||||||
|
compileOnly(libs.annotation)
|
||||||
|
|
||||||
|
implementation(project(":extensions:spotify:utils"))
|
||||||
|
implementation(libs.nanohttpd)
|
||||||
|
implementation(libs.protobuf.javalite)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protobuf {
|
||||||
|
protoc {
|
||||||
|
artifact = libs.protobuf.protoc.get().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
generateProtoTasks {
|
||||||
|
all().forEach { task ->
|
||||||
|
task.builtins {
|
||||||
|
create("java") {
|
||||||
|
option("lite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
extensions/spotify/src/main/AndroidManifest.xml
Normal file
1
extensions/spotify/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest/>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package app.revanced.extension.spotify.layout.hide.createbutton;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.spotify.shared.ComponentFilters.*;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public final class HideCreateButtonPatch {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of component filters that match whether a navigation bar item is the Create button.
|
||||||
|
* The main approach used is matching the resource id for the Create button title.
|
||||||
|
*/
|
||||||
|
private static final List<ComponentFilter> CREATE_BUTTON_COMPONENT_FILTERS = List.of(
|
||||||
|
new ResourceIdComponentFilter("navigationbar_musicappitems_create_title", "string"),
|
||||||
|
// Temporary fallback and fix for APKs merged with AntiSplit-M not having resources properly encoded,
|
||||||
|
// and thus getting the resource identifier for the Create button title always return 0.
|
||||||
|
// FIXME: Remove this once the above issue is no longer relevant.
|
||||||
|
new StringComponentFilter("spotify:create-menu")
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component filter for the old id of the resource which contained the Create button title.
|
||||||
|
* Used in older versions of the app.
|
||||||
|
*/
|
||||||
|
private static final ResourceIdComponentFilter OLD_CREATE_BUTTON_COMPONENT_FILTER =
|
||||||
|
new ResourceIdComponentFilter("bottom_navigation_bar_create_tab_title", "string");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point. This method is called on every navigation bar item to check whether it is the Create button.
|
||||||
|
* If the navigation bar item is the Create button, it returns null to erase it.
|
||||||
|
* The method fingerprint used to patch ensures we can safely return null here.
|
||||||
|
*/
|
||||||
|
public static Object returnNullIfIsCreateButton(Object navigationBarItem) {
|
||||||
|
if (navigationBarItem == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String stringifiedNavigationBarItem = navigationBarItem.toString();
|
||||||
|
|
||||||
|
for (ComponentFilter componentFilter : CREATE_BUTTON_COMPONENT_FILTERS) {
|
||||||
|
if (componentFilter.filterUnavailable()) {
|
||||||
|
Logger.printInfo(() -> "returnNullIfIsCreateButton: Filter " +
|
||||||
|
componentFilter.getFilterRepresentation() + " not available, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stringifiedNavigationBarItem.contains(componentFilter.getFilterValue())) {
|
||||||
|
Logger.printInfo(() -> "Hiding Create button because the navigation bar item " +
|
||||||
|
navigationBarItem + " matched the filter " + componentFilter.getFilterRepresentation());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "returnNullIfIsCreateButton failure", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigationBarItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point. Called in older versions of the app. Returns whether the old navigation bar item is the old
|
||||||
|
* Create button.
|
||||||
|
*/
|
||||||
|
public static boolean isOldCreateButton(int oldNavigationBarItemTitleResId) {
|
||||||
|
if (OLD_CREATE_BUTTON_COMPONENT_FILTER.filterUnavailable()) {
|
||||||
|
Logger.printInfo(() -> "Skipping hiding old Create button because the resource id for " +
|
||||||
|
OLD_CREATE_BUTTON_COMPONENT_FILTER.resourceName + " is not available");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldNavigationBarItemTitleResId == OLD_CREATE_BUTTON_COMPONENT_FILTER.getResourceId()) {
|
||||||
|
Logger.printInfo(() -> "Hiding old Create button because the navigation bar item title resource id" +
|
||||||
|
" matched " + OLD_CREATE_BUTTON_COMPONENT_FILTER.getFilterRepresentation());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package app.revanced.extension.spotify.layout.theme;
|
||||||
|
|
||||||
|
import android.graphics.Color;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public final class CustomThemePatch {
|
||||||
|
|
||||||
|
private static final int BACKGROUND_COLOR = getColorFromString("@color/gray_7");
|
||||||
|
private static final int BACKGROUND_COLOR_SECONDARY = getColorFromString("@color/gray_15");
|
||||||
|
private static final int ACCENT_COLOR = getColorFromString("@color/spotify_green_157");
|
||||||
|
private static final int ACCENT_PRESSED_COLOR =
|
||||||
|
getColorFromString("@color/dark_brightaccent_background_press");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an int representation of the color resource or hex code.
|
||||||
|
*/
|
||||||
|
private static int getColorFromString(String colorString) {
|
||||||
|
try {
|
||||||
|
return Utils.getColorFromString(colorString);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "Invalid color string: " + colorString, ex);
|
||||||
|
return Color.BLACK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point. Returns an int representation of the replaced color from the original color.
|
||||||
|
*/
|
||||||
|
public static int replaceColor(int originalColor) {
|
||||||
|
switch (originalColor) {
|
||||||
|
// Playlist background color.
|
||||||
|
case 0xFF121212:
|
||||||
|
return BACKGROUND_COLOR;
|
||||||
|
|
||||||
|
// Share menu background color.
|
||||||
|
case 0xFF1F1F1F:
|
||||||
|
// Home category pills background color.
|
||||||
|
case 0xFF333333:
|
||||||
|
// Settings header background color.
|
||||||
|
case 0xFF282828:
|
||||||
|
// Spotify Connect device list background color.
|
||||||
|
case 0xFF2A2A2A:
|
||||||
|
return BACKGROUND_COLOR_SECONDARY;
|
||||||
|
|
||||||
|
// Some Lottie animations have a color that's slightly off due to rounding errors.
|
||||||
|
case 0xFF1ED760: case 0xFF1ED75F:
|
||||||
|
// Intermediate color used in some animations, same rounding issue.
|
||||||
|
case 0xFF1DB954: case 0xFF1CB854:
|
||||||
|
return ACCENT_COLOR;
|
||||||
|
|
||||||
|
case 0xFF1ABC54:
|
||||||
|
return ACCENT_PRESSED_COLOR;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return originalColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package app.revanced.extension.spotify.misc.fix;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.spotify.login5.v4.proto.Login5.*;
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
|
import com.google.protobuf.MessageLite;
|
||||||
|
import fi.iki.elonen.NanoHTTPD;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.FilterInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static app.revanced.extension.spotify.misc.fix.Session.FAILED_TO_RENEW_SESSION;
|
||||||
|
import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR;
|
||||||
|
|
||||||
|
class LoginRequestListener extends NanoHTTPD {
|
||||||
|
LoginRequestListener(int port) {
|
||||||
|
super(port);
|
||||||
|
|
||||||
|
try {
|
||||||
|
start();
|
||||||
|
} catch (IOException ex) {
|
||||||
|
Logger.printException(() -> "Failed to start login request listener on port " + port, ex);
|
||||||
|
throw new RuntimeException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public Response serve(IHTTPSession request) {
|
||||||
|
Logger.printInfo(() -> "Serving request for URI: " + request.getUri());
|
||||||
|
|
||||||
|
InputStream requestBodyInputStream = getRequestBodyInputStream(request);
|
||||||
|
|
||||||
|
LoginRequest loginRequest;
|
||||||
|
try {
|
||||||
|
loginRequest = LoginRequest.parseFrom(requestBodyInputStream);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
Logger.printException(() -> "Failed to parse LoginRequest", ex);
|
||||||
|
return newResponse(INTERNAL_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageLite loginResponse;
|
||||||
|
|
||||||
|
// A request may be made concurrently by Spotify,
|
||||||
|
// however a webview can only handle one request at a time due to singleton cookie manager.
|
||||||
|
// Therefore, synchronize to ensure that only one webview handles the request at a time.
|
||||||
|
synchronized (this) {
|
||||||
|
try {
|
||||||
|
loginResponse = getLoginResponse(loginRequest);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "Failed to get login response", ex);
|
||||||
|
return newResponse(INTERNAL_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newResponse(Response.Status.OK, loginResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static LoginResponse getLoginResponse(@NonNull LoginRequest loginRequest) {
|
||||||
|
Session session;
|
||||||
|
|
||||||
|
if (!loginRequest.hasStoredCredential()) {
|
||||||
|
Logger.printInfo(() -> "Received request for initial login");
|
||||||
|
session = WebApp.currentSession; // Session obtained from WebApp.launchLogin, can be null if still in progress.
|
||||||
|
} else {
|
||||||
|
Logger.printInfo(() -> "Received request to restore saved session");
|
||||||
|
session = Session.read(loginRequest.getStoredCredential().getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
return toLoginResponse(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LoginResponse toLoginResponse(@Nullable Session session) {
|
||||||
|
LoginResponse.Builder builder = LoginResponse.newBuilder();
|
||||||
|
|
||||||
|
if (session == null) {
|
||||||
|
Logger.printException(() -> "Session is null. An initial login may still be in progress, returning try again later error");
|
||||||
|
builder.setError(LoginError.TRY_AGAIN_LATER);
|
||||||
|
} else if (session.accessTokenExpired()) {
|
||||||
|
Logger.printInfo(() -> "Access token expired, renewing session");
|
||||||
|
WebApp.renewSessionBlocking(session.cookies);
|
||||||
|
return toLoginResponse(WebApp.currentSession);
|
||||||
|
} else if (session.username == null) {
|
||||||
|
Logger.printException(() -> "Session username is null, likely caused by invalid cookies, returning invalid credentials error");
|
||||||
|
session.delete();
|
||||||
|
builder.setError(LoginError.INVALID_CREDENTIALS);
|
||||||
|
} else if (session == FAILED_TO_RENEW_SESSION) {
|
||||||
|
Logger.printException(() -> "Failed to renew session, likely caused by a timeout, returning try again later error");
|
||||||
|
builder.setError(LoginError.TRY_AGAIN_LATER);
|
||||||
|
} else {
|
||||||
|
session.save();
|
||||||
|
Logger.printInfo(() -> "Returning session for username: " + session.username);
|
||||||
|
builder.setOk(LoginOk.newBuilder()
|
||||||
|
.setUsername(session.username)
|
||||||
|
.setAccessToken(session.accessToken)
|
||||||
|
.setStoredCredential(ByteString.fromHex("00")) // Placeholder, as it cannot be null or empty.
|
||||||
|
.setAccessTokenExpiresIn(session.accessTokenExpiresInSeconds())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static InputStream limitedInputStream(InputStream inputStream, long contentLength) {
|
||||||
|
return new FilterInputStream(inputStream) {
|
||||||
|
private long remaining = contentLength;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
if (remaining <= 0) return -1;
|
||||||
|
int result = super.read();
|
||||||
|
if (result != -1) remaining--;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] b, int off, int len) throws IOException {
|
||||||
|
if (remaining <= 0) return -1;
|
||||||
|
len = (int) Math.min(len, remaining);
|
||||||
|
int result = super.read(b, off, len);
|
||||||
|
if (result != -1) remaining -= result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static InputStream getRequestBodyInputStream(@NonNull IHTTPSession request) {
|
||||||
|
long requestContentLength =
|
||||||
|
Long.parseLong(Objects.requireNonNull(request.getHeaders().get("content-length")));
|
||||||
|
return limitedInputStream(request.getInputStream(), requestContentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@SuppressWarnings("SameParameterValue")
|
||||||
|
@NonNull
|
||||||
|
private static Response newResponse(Response.Status status) {
|
||||||
|
return newResponse(status, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static Response newResponse(Response.IStatus status, MessageLite messageLite) {
|
||||||
|
if (messageLite == null) {
|
||||||
|
return newFixedLengthResponse(status, "application/x-protobuf", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] messageBytes = messageLite.toByteArray();
|
||||||
|
InputStream stream = new ByteArrayInputStream(messageBytes);
|
||||||
|
return newFixedLengthResponse(status, "application/x-protobuf", stream, messageBytes.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package app.revanced.extension.spotify.misc.fix;
|
||||||
|
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import static android.content.Context.MODE_PRIVATE;
|
||||||
|
|
||||||
|
class Session {
|
||||||
|
/**
|
||||||
|
* Username of the account. Null if this session does not have an authenticated user.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
final String username;
|
||||||
|
/**
|
||||||
|
* Access token for this session.
|
||||||
|
*/
|
||||||
|
final String accessToken;
|
||||||
|
/**
|
||||||
|
* Session expiration timestamp in milliseconds.
|
||||||
|
*/
|
||||||
|
final Long expirationTime;
|
||||||
|
/**
|
||||||
|
* Authentication cookies for this session.
|
||||||
|
*/
|
||||||
|
final String cookies;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session that represents a failed attempt to renew the session.
|
||||||
|
*/
|
||||||
|
static final Session FAILED_TO_RENEW_SESSION = new Session("", "", "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param username Username of the account. Empty if this session does not have an authenticated user.
|
||||||
|
* @param accessToken Access token for this session.
|
||||||
|
* @param cookies Authentication cookies for this session.
|
||||||
|
*/
|
||||||
|
Session(@Nullable String username, String accessToken, String cookies) {
|
||||||
|
this(username, accessToken, System.currentTimeMillis() + 60 * 60 * 1000, cookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Session(@Nullable String username, String accessToken, long expirationTime, String cookies) {
|
||||||
|
this.username = username;
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.expirationTime = expirationTime;
|
||||||
|
this.cookies = cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The number of milliseconds until the access token expires.
|
||||||
|
*/
|
||||||
|
long accessTokenExpiresInMillis() {
|
||||||
|
long currentTime = System.currentTimeMillis();
|
||||||
|
return expirationTime - currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The number of seconds until the access token expires.
|
||||||
|
*/
|
||||||
|
int accessTokenExpiresInSeconds() {
|
||||||
|
return (int) accessTokenExpiresInMillis() / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return True if the access token has expired, false otherwise.
|
||||||
|
*/
|
||||||
|
boolean accessTokenExpired() {
|
||||||
|
return accessTokenExpiresInMillis() <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void save() {
|
||||||
|
Logger.printInfo(() -> "Saving session: " + this);
|
||||||
|
|
||||||
|
SharedPreferences.Editor editor = Utils.getContext().getSharedPreferences("revanced", MODE_PRIVATE).edit();
|
||||||
|
|
||||||
|
String json;
|
||||||
|
try {
|
||||||
|
json = new JSONObject()
|
||||||
|
.put("accessToken", accessToken)
|
||||||
|
.put("expirationTime", expirationTime)
|
||||||
|
.put("cookies", cookies).toString();
|
||||||
|
} catch (JSONException ex) {
|
||||||
|
Logger.printException(() -> "Failed to convert session to stored credential", ex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.putString("session_" + username, json);
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
void delete() {
|
||||||
|
Logger.printInfo(() -> "Deleting saved session for username: " + username);
|
||||||
|
SharedPreferences.Editor editor = Utils.getContext().getSharedPreferences("revanced", MODE_PRIVATE).edit();
|
||||||
|
editor.remove("session_" + username);
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
static Session read(String username) {
|
||||||
|
Logger.printInfo(() -> "Reading saved session for username: " + username);
|
||||||
|
|
||||||
|
SharedPreferences sharedPreferences = Utils.getContext().getSharedPreferences("revanced", MODE_PRIVATE);
|
||||||
|
String savedJson = sharedPreferences.getString("session_" + username, null);
|
||||||
|
if (savedJson == null) {
|
||||||
|
Logger.printInfo(() -> "No session found in shared preferences");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSONObject json = new JSONObject(savedJson);
|
||||||
|
String accessToken = json.getString("accessToken");
|
||||||
|
long expirationTime = json.getLong("expirationTime");
|
||||||
|
String cookies = json.getString("cookies");
|
||||||
|
|
||||||
|
return new Session(username, accessToken, expirationTime, cookies);
|
||||||
|
} catch (JSONException ex) {
|
||||||
|
Logger.printException(() -> "Failed to read session from shared preferences", ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Session(" +
|
||||||
|
"username=" + username +
|
||||||
|
", accessToken=" + accessToken +
|
||||||
|
", expirationTime=" + expirationTime +
|
||||||
|
", cookies=" + cookies +
|
||||||
|
')';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package app.revanced.extension.spotify.misc.fix;
|
||||||
|
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public class SpoofClientPatch {
|
||||||
|
private static LoginRequestListener listener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point.
|
||||||
|
* <br>
|
||||||
|
* Launch login server.
|
||||||
|
*/
|
||||||
|
public static void launchListener(int port) {
|
||||||
|
if (listener != null) {
|
||||||
|
Logger.printInfo(() -> "Listener already running on port " + port);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Logger.printInfo(() -> "Launching listener on port " + port);
|
||||||
|
listener = new LoginRequestListener(port);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "launchListener failure", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point.
|
||||||
|
* <br>
|
||||||
|
* Launch login web view.
|
||||||
|
*/
|
||||||
|
public static void launchLogin(LayoutInflater inflater) {
|
||||||
|
try {
|
||||||
|
WebApp.launchLogin(inflater.getContext());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "launchLogin failure", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point.
|
||||||
|
* <br>
|
||||||
|
* Set handler to call the native login after the webview login.
|
||||||
|
*/
|
||||||
|
public static void setNativeLoginHandler(View startLoginButton) {
|
||||||
|
WebApp.nativeLoginHandler = (() -> {
|
||||||
|
startLoginButton.setSoundEffectsEnabled(false);
|
||||||
|
startLoginButton.performClick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package app.revanced.extension.spotify.misc.fix;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.app.Dialog;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.view.Window;
|
||||||
|
import android.view.WindowInsets;
|
||||||
|
import android.webkit.*;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
import app.revanced.extension.spotify.UserAgent;
|
||||||
|
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static app.revanced.extension.spotify.misc.fix.Session.FAILED_TO_RENEW_SESSION;
|
||||||
|
|
||||||
|
class WebApp {
|
||||||
|
private static final String OPEN_SPOTIFY_COM = "open.spotify.com";
|
||||||
|
private static final String OPEN_SPOTIFY_COM_URL = "https://" + OPEN_SPOTIFY_COM;
|
||||||
|
private static final String OPEN_SPOTIFY_COM_PREFERENCES_URL = OPEN_SPOTIFY_COM_URL + "/preferences";
|
||||||
|
private static final String ACCOUNTS_SPOTIFY_COM_LOGIN_URL = "https://accounts.spotify.com/login?allow_password=1"
|
||||||
|
+ "&continue=https%3A%2F%2Fopen.spotify.com%2Fpreferences";
|
||||||
|
|
||||||
|
private static final int GET_SESSION_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final String JAVASCRIPT_INTERFACE_NAME = "androidInterface";
|
||||||
|
private static final String USER_AGENT = getWebUserAgent();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A session obtained from the webview after logging in.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
static volatile Session currentSession = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current webview in use. Any use of the object must be done on the main thread.
|
||||||
|
*/
|
||||||
|
@SuppressLint("StaticFieldLeak")
|
||||||
|
private static volatile WebView currentWebView;
|
||||||
|
|
||||||
|
interface NativeLoginHandler {
|
||||||
|
void login();
|
||||||
|
}
|
||||||
|
|
||||||
|
static NativeLoginHandler nativeLoginHandler;
|
||||||
|
|
||||||
|
static void launchLogin(Context context) {
|
||||||
|
final Dialog dialog = newDialog(context);
|
||||||
|
|
||||||
|
Utils.runOnBackgroundThread(() -> {
|
||||||
|
Logger.printInfo(() -> "Launching login");
|
||||||
|
|
||||||
|
// A session must be obtained from a login. Repeat until a session is acquired.
|
||||||
|
boolean isAcquired = false;
|
||||||
|
do {
|
||||||
|
CountDownLatch onLoggedInLatch = new CountDownLatch(1);
|
||||||
|
CountDownLatch getSessionLatch = new CountDownLatch(1);
|
||||||
|
|
||||||
|
// Can't use Utils.getContext() here, because autofill won't work.
|
||||||
|
// See https://stackoverflow.com/a/79182053/11213244.
|
||||||
|
launchWebView(context, ACCOUNTS_SPOTIFY_COM_LOGIN_URL, new WebViewCallback() {
|
||||||
|
@Override
|
||||||
|
void onInitialized(WebView webView) {
|
||||||
|
super.onInitialized(webView);
|
||||||
|
|
||||||
|
dialog.setContentView(webView);
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void onLoggedIn(String cookies) {
|
||||||
|
onLoggedInLatch.countDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void onReceivedSession(Session session) {
|
||||||
|
super.onReceivedSession(session);
|
||||||
|
|
||||||
|
getSessionLatch.countDown();
|
||||||
|
dialog.dismiss();
|
||||||
|
|
||||||
|
try {
|
||||||
|
nativeLoginHandler.login();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "nativeLoginHandler failure", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait indefinitely until the user logs in.
|
||||||
|
onLoggedInLatch.await();
|
||||||
|
// Wait until the session is received, or timeout.
|
||||||
|
isAcquired = getSessionLatch.await(GET_SESSION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Logger.printException(() -> "Login interrupted", ex);
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
} while (!isAcquired);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static void renewSessionBlocking(String cookies) {
|
||||||
|
Logger.printInfo(() -> "Renewing session with cookies: " + cookies);
|
||||||
|
|
||||||
|
CountDownLatch getSessionLatch = new CountDownLatch(1);
|
||||||
|
|
||||||
|
launchWebView(Utils.getContext(), OPEN_SPOTIFY_COM_PREFERENCES_URL, new WebViewCallback() {
|
||||||
|
@Override
|
||||||
|
public void onInitialized(WebView webView) {
|
||||||
|
setCookies(cookies);
|
||||||
|
super.onInitialized(webView);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onReceivedSession(Session session) {
|
||||||
|
super.onReceivedSession(session);
|
||||||
|
getSessionLatch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
boolean isAcquired = false;
|
||||||
|
try {
|
||||||
|
isAcquired = getSessionLatch.await(GET_SESSION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Logger.printException(() -> "Session renewal interrupted", ex);
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAcquired) {
|
||||||
|
Logger.printException(() -> "Failed to retrieve session within " + GET_SESSION_TIMEOUT_SECONDS + " seconds");
|
||||||
|
currentSession = FAILED_TO_RENEW_SESSION;
|
||||||
|
destructWebView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All methods are called on the main thread.
|
||||||
|
*/
|
||||||
|
abstract static class WebViewCallback {
|
||||||
|
void onInitialized(WebView webView) {
|
||||||
|
currentWebView = webView;
|
||||||
|
currentSession = null; // Reset current session.
|
||||||
|
}
|
||||||
|
|
||||||
|
void onLoggedIn(String cookies) {
|
||||||
|
}
|
||||||
|
|
||||||
|
void onReceivedSession(Session session) {
|
||||||
|
Logger.printInfo(() -> "Received session: " + session);
|
||||||
|
currentSession = session;
|
||||||
|
|
||||||
|
destructWebView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private static void launchWebView(
|
||||||
|
Context context,
|
||||||
|
String initialUrl,
|
||||||
|
WebViewCallback webViewCallback
|
||||||
|
) {
|
||||||
|
Utils.runOnMainThreadNowOrLater(() -> {
|
||||||
|
WebView webView = new WebView(context);
|
||||||
|
WebSettings settings = webView.getSettings();
|
||||||
|
settings.setDomStorageEnabled(true);
|
||||||
|
settings.setJavaScriptEnabled(true);
|
||||||
|
settings.setUserAgentString(USER_AGENT);
|
||||||
|
|
||||||
|
// WebViewClient is always called off the main thread,
|
||||||
|
// but callback interface methods are called on the main thread.
|
||||||
|
webView.setWebViewClient(new WebViewClient() {
|
||||||
|
@Override
|
||||||
|
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||||
|
if (OPEN_SPOTIFY_COM.equals(request.getUrl().getHost())) {
|
||||||
|
Utils.runOnMainThread(() -> webViewCallback.onLoggedIn(getCurrentCookies()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.shouldInterceptRequest(view, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPageStarted(WebView view, String url, Bitmap favicon) {
|
||||||
|
Logger.printInfo(() -> "Page started loading: " + url);
|
||||||
|
|
||||||
|
if (!url.startsWith(OPEN_SPOTIFY_COM_URL)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.printInfo(() -> "Evaluating script to get session on url: " + url);
|
||||||
|
String getSessionScript = "Object.defineProperty(Object.prototype, \"_username\", {" +
|
||||||
|
" configurable: true," +
|
||||||
|
" set(username) {" +
|
||||||
|
" accessToken = this._builder?.accessToken;" +
|
||||||
|
" if (accessToken) {" +
|
||||||
|
" " + JAVASCRIPT_INTERFACE_NAME + ".getSession(username, accessToken);" +
|
||||||
|
" delete Object.prototype._username;" +
|
||||||
|
" }" +
|
||||||
|
" " +
|
||||||
|
" Object.defineProperty(this, \"_username\", {" +
|
||||||
|
" configurable: true," +
|
||||||
|
" enumerable: true," +
|
||||||
|
" writable: true," +
|
||||||
|
" value: username" +
|
||||||
|
" })" +
|
||||||
|
" " +
|
||||||
|
" }" +
|
||||||
|
"});" +
|
||||||
|
"if (new URLSearchParams(window.location.search).get('_authfailed') != null) {" +
|
||||||
|
" " + JAVASCRIPT_INTERFACE_NAME + ".getSession(null, null);" +
|
||||||
|
"}";
|
||||||
|
|
||||||
|
view.evaluateJavascript(getSessionScript, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
webView.addJavascriptInterface(new Object() {
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@JavascriptInterface
|
||||||
|
public void getSession(String username, String accessToken) {
|
||||||
|
Session session = new Session(username, accessToken, getCurrentCookies());
|
||||||
|
Utils.runOnMainThread(() -> webViewCallback.onReceivedSession(session));
|
||||||
|
}
|
||||||
|
}, JAVASCRIPT_INTERFACE_NAME);
|
||||||
|
|
||||||
|
CookieManager.getInstance().removeAllCookies((anyRemoved) -> {
|
||||||
|
Logger.printInfo(() -> "Loading URL: " + initialUrl);
|
||||||
|
webView.loadUrl(initialUrl);
|
||||||
|
|
||||||
|
Logger.printInfo(() -> "WebView initialized with user agent: " + USER_AGENT);
|
||||||
|
webViewCallback.onInitialized(webView);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void destructWebView() {
|
||||||
|
Utils.runOnMainThreadNowOrLater(() -> {
|
||||||
|
currentWebView.stopLoading();
|
||||||
|
currentWebView.destroy();
|
||||||
|
currentWebView = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getWebUserAgent() {
|
||||||
|
String userAgentString = WebSettings.getDefaultUserAgent(Utils.getContext());
|
||||||
|
try {
|
||||||
|
return new UserAgent(userAgentString)
|
||||||
|
.withCommentReplaced("Android", "Windows NT 10.0; Win64; x64")
|
||||||
|
.withoutProduct("Mobile")
|
||||||
|
.toString();
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edge/137.0.0.0";
|
||||||
|
String fallback = userAgentString;
|
||||||
|
Logger.printException(() -> "Failed to get user agent, falling back to " + fallback, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userAgentString;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static Dialog newDialog(Context context) {
|
||||||
|
Dialog dialog = new Dialog(context, android.R.style.Theme_Black_NoTitleBar_Fullscreen);
|
||||||
|
dialog.setCancelable(false);
|
||||||
|
|
||||||
|
// Ensure that the keyboard does not cover the webview content.
|
||||||
|
Window window = dialog.getWindow();
|
||||||
|
//noinspection StatementWithEmptyBody
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
window.getDecorView().setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
|
v.setPadding(0, 0, 0, insets.getInsets(WindowInsets.Type.ime()).bottom);
|
||||||
|
|
||||||
|
return WindowInsets.CONSUMED;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// TODO: Implement for lower Android versions.
|
||||||
|
}
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getCurrentCookies() {
|
||||||
|
CookieManager cookieManager = CookieManager.getInstance();
|
||||||
|
return cookieManager.getCookie(OPEN_SPOTIFY_COM_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setCookies(@NonNull String cookies) {
|
||||||
|
CookieManager cookieManager = CookieManager.getInstance();
|
||||||
|
|
||||||
|
String[] cookiesList = cookies.split(";");
|
||||||
|
for (String cookie : cookiesList) {
|
||||||
|
cookieManager.setCookie(OPEN_SPOTIFY_COM_URL, cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package app.revanced.extension.spotify.misc.privacy;
|
||||||
|
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public final class SanitizeSharingLinksPatch {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters that are considered undesirable and should be stripped away.
|
||||||
|
*/
|
||||||
|
private static final List<String> SHARE_PARAMETERS_TO_REMOVE = List.of(
|
||||||
|
"si", // Share tracking parameter.
|
||||||
|
"utm_source" // Share source, such as "copy-link".
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection point.
|
||||||
|
*/
|
||||||
|
public static String sanitizeUrl(String url) {
|
||||||
|
try {
|
||||||
|
Uri uri = Uri.parse(url);
|
||||||
|
Uri.Builder builder = uri.buildUpon().clearQuery();
|
||||||
|
|
||||||
|
for (String paramName : uri.getQueryParameterNames()) {
|
||||||
|
if (!SHARE_PARAMETERS_TO_REMOVE.contains(paramName)) {
|
||||||
|
for (String value : uri.getQueryParameters(paramName)) {
|
||||||
|
builder.appendQueryParameter(paramName, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String sanitizedUrl = builder.build().toString();
|
||||||
|
Logger.printInfo(() -> "Sanitized url " + url + " to " + sanitizedUrl);
|
||||||
|
return sanitizedUrl;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Logger.printException(() -> "sanitizeUrl failure with " + url, ex);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package app.revanced.extension.spotify.shared;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import app.revanced.extension.shared.Logger;
|
||||||
|
import app.revanced.extension.shared.Utils;
|
||||||
|
|
||||||
|
public final class ComponentFilters {
|
||||||
|
|
||||||
|
public interface ComponentFilter {
|
||||||
|
@NonNull
|
||||||
|
String getFilterValue();
|
||||||
|
String getFilterRepresentation();
|
||||||
|
default boolean filterUnavailable() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class ResourceIdComponentFilter implements ComponentFilter {
|
||||||
|
|
||||||
|
public final String resourceName;
|
||||||
|
public final String resourceType;
|
||||||
|
// Android resources are always positive, so -1 is a valid sentinel value to indicate it has not been loaded.
|
||||||
|
// 0 is returned when a resource has not been found.
|
||||||
|
private int resourceId = -1;
|
||||||
|
@Nullable
|
||||||
|
private String stringfiedResourceId;
|
||||||
|
|
||||||
|
public ResourceIdComponentFilter(String resourceName, String resourceType) {
|
||||||
|
this.resourceName = resourceName;
|
||||||
|
this.resourceType = resourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getResourceId() {
|
||||||
|
if (resourceId == -1) {
|
||||||
|
resourceId = Utils.getResourceIdentifier(resourceName, resourceType);
|
||||||
|
}
|
||||||
|
return resourceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public String getFilterValue() {
|
||||||
|
if (stringfiedResourceId == null) {
|
||||||
|
stringfiedResourceId = Integer.toString(getResourceId());
|
||||||
|
}
|
||||||
|
return stringfiedResourceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getFilterRepresentation() {
|
||||||
|
boolean resourceFound = getResourceId() != 0;
|
||||||
|
return (resourceFound ? getFilterValue() + " (" : "") + resourceName + (resourceFound ? ")" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean filterUnavailable() {
|
||||||
|
boolean resourceNotFound = getResourceId() == 0;
|
||||||
|
if (resourceNotFound) {
|
||||||
|
Logger.printInfo(() -> "Resource id for " + resourceName + " was not found");
|
||||||
|
}
|
||||||
|
return resourceNotFound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class StringComponentFilter implements ComponentFilter {
|
||||||
|
|
||||||
|
public final String string;
|
||||||
|
|
||||||
|
public StringComponentFilter(String string) {
|
||||||
|
this.string = string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public String getFilterValue() {
|
||||||
|
return string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getFilterRepresentation() {
|
||||||
|
return string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
extensions/spotify/src/main/proto/login5.proto
Normal file
43
extensions/spotify/src/main/proto/login5.proto
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package spotify.login5.v4;
|
||||||
|
|
||||||
|
option optimize_for = LITE_RUNTIME;
|
||||||
|
option java_package = "app.revanced.extension.spotify.login5.v4.proto";
|
||||||
|
|
||||||
|
message StoredCredential {
|
||||||
|
string username = 1;
|
||||||
|
bytes data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginRequest {
|
||||||
|
oneof login_method {
|
||||||
|
StoredCredential stored_credential = 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginOk {
|
||||||
|
string username = 1;
|
||||||
|
string access_token = 2;
|
||||||
|
bytes stored_credential = 3;
|
||||||
|
int32 access_token_expires_in = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginResponse {
|
||||||
|
oneof response {
|
||||||
|
LoginOk ok = 1;
|
||||||
|
LoginError error = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoginError {
|
||||||
|
UNKNOWN_ERROR = 0;
|
||||||
|
INVALID_CREDENTIALS = 1;
|
||||||
|
BAD_REQUEST = 2;
|
||||||
|
UNSUPPORTED_LOGIN_PROTOCOL = 3;
|
||||||
|
TIMEOUT = 4;
|
||||||
|
UNKNOWN_IDENTIFIER = 5;
|
||||||
|
TOO_MANY_ATTEMPTS = 6;
|
||||||
|
INVALID_PHONENUMBER = 7;
|
||||||
|
TRY_AGAIN_LATER = 8;
|
||||||
|
}
|
||||||
17
extensions/spotify/stub/build.gradle.kts
Normal file
17
extensions/spotify/stub/build.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.revanced.extension"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
}
|
||||||
1
extensions/spotify/stub/src/main/AndroidManifest.xml
Normal file
1
extensions/spotify/stub/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest/>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.spotify.browsita.v1.resolved;
|
||||||
|
|
||||||
|
public final class Section {
|
||||||
|
public static final int BRAND_ADS_FIELD_NUMBER = 6;
|
||||||
|
public int sectionTypeCase_;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user