Compare commits
986
Commits
1.13.0
...
33fd89041c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33fd89041c | ||
|
|
40271d744b | ||
|
|
e6eba042b9 | ||
|
|
aeb9b47228 | ||
|
|
8b2ea9a418 | ||
|
|
97bca3eecc | ||
|
|
d7c64f540b | ||
|
|
793ba815f7 | ||
|
|
d901326640 | ||
|
|
de2321ce8f | ||
|
|
39398365e5 | ||
|
|
baf8333b70 | ||
|
|
5c406ac8b0 | ||
|
|
f07ff61069 | ||
|
|
2163238f48 | ||
|
|
1f4b93bddd | ||
|
|
ab2c60824a | ||
|
|
dbe455350d | ||
|
|
78cb123af1 | ||
|
|
5cd2496f7f | ||
|
|
06d4abd916 | ||
|
|
881ad21ea2 | ||
|
|
257bfe843e | ||
|
|
49eb3bc234 | ||
|
|
3006e25f3d | ||
|
|
79fbe9cbd0 | ||
|
|
36b0eeef5a | ||
|
|
eeeeb3ca0d | ||
|
|
345dbc2b86 | ||
|
|
cca4adc6a3 | ||
|
|
8decbed291 | ||
|
|
960ac5d2fe | ||
|
|
20aef7f77c | ||
|
|
96d1ba780b | ||
|
|
8383821324 | ||
|
|
f46a5e4652 | ||
|
|
d8d2e8299d | ||
|
|
284af1e6fb | ||
|
|
c7576a3941 | ||
|
|
c216bc3b82 | ||
|
|
2ac3b179c0 | ||
|
|
aa2269fd9f | ||
|
|
793a17afea | ||
|
|
dab6dcc04d | ||
|
|
be398a6ab6 | ||
|
|
80cffe479f | ||
|
|
30c5fe38c4 | ||
|
|
87b538d703 | ||
|
|
affe4ffbf1 | ||
|
|
3d74869187 | ||
|
|
58d90ff49c | ||
|
|
07217f1481 | ||
|
|
cf87df35a6 | ||
|
|
c69cb76ecd | ||
|
|
0ad0cc771f | ||
|
|
ca8c6883f9 | ||
|
|
812d9d9860 | ||
|
|
f2673ddd9a | ||
|
|
a6f789f446 | ||
|
|
bba6ea4b3c | ||
|
|
ae6c664f03 | ||
|
|
2b3755a09c | ||
|
|
efde6e9f3d | ||
|
|
cff64839bb | ||
|
|
d199b58f89 | ||
|
|
8005f9be56 | ||
|
|
c13186da78 | ||
|
|
f407d38421 | ||
|
|
19af89d58a | ||
|
|
b7b2d07d60 | ||
|
|
e8bba2a2e5 | ||
|
|
f8e01265d4 | ||
|
|
e0c5763a47 | ||
|
|
fe3fd59083 | ||
|
|
09693f91c7 | ||
|
|
b69500a441 | ||
|
|
f344a9a0a5 | ||
|
|
ef62454c00 | ||
|
|
fe2cd1b4f4 | ||
|
|
19b9fa3581 | ||
|
|
431a8565ee | ||
|
|
3880f757a5 | ||
|
|
acfc81056b | ||
|
|
5f1200a9b3 | ||
|
|
f370ae64ad | ||
|
|
509c818ed0 | ||
|
|
124d1ff638 | ||
|
|
0348eba0fd | ||
|
|
1d27292bf1 | ||
|
|
1619f6cce6 | ||
|
|
489538baaa | ||
|
|
8794b7cc4d | ||
|
|
ebd114d10e | ||
|
|
f17dd93f27 | ||
|
|
899c2e14c8 | ||
|
|
ae50280fb1 | ||
|
|
a2bd809fbc | ||
|
|
963f129bcf | ||
|
|
37c0148d0b | ||
|
|
1fa485e31f | ||
|
|
dceea3b2ff | ||
|
|
bab7770857 | ||
|
|
af9251db0c | ||
|
|
010b990f8d | ||
|
|
0e61e4b00a | ||
|
|
66e43924bd | ||
|
|
3437bb88a2 | ||
|
|
f6e0733080 | ||
|
|
add2676292 | ||
|
|
95839dbb60 | ||
|
|
6dbc2b2e67 | ||
|
|
fc89ca007e | ||
|
|
a75fba27cd | ||
|
|
c2f1233360 | ||
|
|
4e3cadc778 | ||
|
|
73f544c14e | ||
|
|
025a947bb0 | ||
|
|
8927074cf1 | ||
|
|
da6ffe7304 | ||
|
|
956a3ac1fd | ||
|
|
f6b43cb7e3 | ||
|
|
47f3406271 | ||
|
|
4ac4d3da01 | ||
|
|
ea9ed2dc67 | ||
|
|
10ad0b6449 | ||
|
|
8b02116dda | ||
|
|
1578da3b02 | ||
|
|
c77451cae9 | ||
|
|
41b503918b | ||
|
|
5f40d8d474 | ||
|
|
3109a6258b | ||
|
|
cacf578615 | ||
|
|
bb42a84b44 | ||
|
|
d6519d4819 | ||
|
|
df5db83337 | ||
|
|
af4b751425 | ||
|
|
58209b0420 | ||
|
|
d4c39fd72f | ||
|
|
24505cb77c | ||
|
|
80225bfe6c | ||
|
|
14b24d6c58 | ||
|
|
3c10d3fe00 | ||
|
|
fd6a1fd1ec | ||
|
|
325578b989 | ||
|
|
705e39a50d | ||
|
|
5ac3f4cd44 | ||
|
|
80c3c697d4 | ||
|
|
8cc597836e | ||
|
|
9a214c939b | ||
|
|
11a3201e54 | ||
|
|
5156bd7747 | ||
|
|
e62738519c | ||
|
|
d87de0d3e2 | ||
|
|
2aa2c25cdd | ||
|
|
123a7cd8e3 | ||
|
|
72a46acb46 | ||
|
|
50815c6e91 | ||
|
|
2c6f98b582 | ||
|
|
240bc5d0ae | ||
|
|
e4306a7303 | ||
|
|
15179a82e3 | ||
|
|
1e3b2bebb3 | ||
|
|
2c32cafd38 | ||
|
|
ddd11477f7 | ||
|
|
f3b2d8495a | ||
|
|
a56dd32425 | ||
|
|
873b5e8540 | ||
|
|
34719b599e | ||
|
|
a3a3a7b46d | ||
|
|
1a46f75693 | ||
|
|
90b4d7777e | ||
|
|
c363f4df51 | ||
|
|
4dc0981b34 | ||
|
|
e2772bde04 | ||
|
|
f1666d7a64 | ||
|
|
0195f1c9b8 | ||
|
|
18d2d992a4 | ||
|
|
5582353c44 | ||
|
|
464568f29f | ||
|
|
213a9d56d2 | ||
|
|
f672462bcd | ||
|
|
9a0b0d2c65 | ||
|
|
fcaf4e3182 | ||
|
|
ea40fa9b05 | ||
|
|
2e3436dbe7 | ||
|
|
bd759dedea | ||
|
|
bac78ce018 | ||
|
|
dd95d7aaa2 | ||
|
|
d1bb5b1d87 | ||
|
|
67a3252b05 | ||
|
|
2df08f022f | ||
|
|
65673a0ed9 | ||
|
|
aef20e2e04 | ||
|
|
3bbfc8ac3a | ||
|
|
02e85a8c4d | ||
|
|
8cc531ccec | ||
|
|
30fc750e17 | ||
|
|
0ff40d3d38 | ||
|
|
9fa80db783 | ||
|
|
357503b403 | ||
|
|
10f7b2a7a9 | ||
|
|
99696b11db | ||
|
|
71291b8800 | ||
|
|
3532dbdcd6 | ||
|
|
589ba8432c | ||
|
|
273196bf5d | ||
|
|
a5fadeee3c | ||
|
|
3f784ec1de | ||
|
|
f7a70cdfc3 | ||
|
|
030100280f | ||
|
|
0d76ee43fd | ||
|
|
11a215f070 | ||
|
|
a6e61e07fc | ||
|
|
da94d298ce | ||
|
|
9e74db0f02 | ||
|
|
0d63c2fa3c | ||
|
|
bf44fde58e | ||
|
|
4bc119138f | ||
|
|
c7f5b86843 | ||
|
|
2aa815dce1 | ||
|
|
cf7f18351d | ||
|
|
5ed27963d7 | ||
|
|
85e539f0ba | ||
|
|
15ecb0e3f4 | ||
|
|
db5055da6e | ||
|
|
09e201f3f2 | ||
|
|
6f34083880 | ||
|
|
4e0c42c955 | ||
|
|
935b7dec0c | ||
|
|
d0b0651c7d | ||
|
|
e131a416e8 | ||
|
|
dd526d33bd | ||
|
|
4611a77e04 | ||
|
|
806560cf59 | ||
|
|
fd3f6eb124 | ||
|
|
8e14c6e57b | ||
|
|
20a4f4ab25 | ||
|
|
d4c64f6e66 | ||
|
|
cfb2f15a1e | ||
|
|
927a738f46 | ||
|
|
19482dbd77 | ||
|
|
43b7fb611a | ||
|
|
c39338efb7 | ||
|
|
dbd952eebb | ||
|
|
192039408c | ||
|
|
e38376d4b0 | ||
|
|
a4062cf4d1 | ||
|
|
3c47d36525 | ||
|
|
00090944d5 | ||
|
|
d2cafbb2a3 | ||
|
|
9847b95582 | ||
|
|
986412932b | ||
|
|
f13831cb74 | ||
|
|
5ed1203558 | ||
|
|
acf1fe43bf | ||
|
|
4761ef7416 | ||
|
|
70aaf38f94 | ||
|
|
a92aaf19c9 | ||
|
|
a5b408d316 | ||
|
|
f96a2e63d1 | ||
|
|
a0a9da68da | ||
|
|
a83be94d1f | ||
|
|
cc5dfbfa41 | ||
|
|
57b79b0b06 | ||
|
|
569130087f | ||
|
|
1a7b36b488 | ||
|
|
e8b5fd0425 | ||
|
|
626d5e7a8c | ||
|
|
4d94e84c13 | ||
|
|
863ec659ff | ||
|
|
071fdfc0a3 | ||
|
|
a137e9715a | ||
|
|
c28d1d34ff | ||
|
|
1230022d17 | ||
|
|
9a9f2a8d70 | ||
|
|
f51da1a948 | ||
|
|
c1ac592855 | ||
|
|
371103908b | ||
|
|
a2a029bb28 | ||
|
|
9266fba415 | ||
|
|
0963e2f564 | ||
|
|
72a5906a9b | ||
|
|
4dbd54c495 | ||
|
|
6219acb15c | ||
|
|
c4179bef5d | ||
|
|
117a1eaab8 | ||
|
|
78e31e4fb9 | ||
|
|
3da86fd745 | ||
|
|
e0c710ff5f | ||
|
|
2946459f1d | ||
|
|
5b5f328e06 | ||
|
|
b496e18e7a | ||
|
|
4abb2ab0ce | ||
|
|
16edaacf37 | ||
|
|
b69aea9d14 | ||
|
|
656d372612 | ||
|
|
09a2ebcc61 | ||
|
|
b74d0e9006 | ||
|
|
a7c81130cd | ||
|
|
c0b75cdbe9 | ||
|
|
2c15c74697 | ||
|
|
ba9308bfbc | ||
|
|
21050b49d4 | ||
|
|
842da49b08 | ||
|
|
44fb2b70a5 | ||
|
|
9ef95f56cf | ||
|
|
b5b08404bd | ||
|
|
17abca7f7d | ||
|
|
c8d4782b2a | ||
|
|
e0af610c2c | ||
|
|
ddc645c323 | ||
|
|
779dd1f8fd | ||
|
|
cf10e2ff6d | ||
|
|
55d1094e55 | ||
|
|
8248ac6e2f | ||
|
|
4b2ac8ae5e | ||
|
|
085e0938cb | ||
|
|
9a2c0d7121 | ||
|
|
aa8834b941 | ||
|
|
d6b524bc47 | ||
|
|
1ca960995f | ||
|
|
31be34f551 | ||
|
|
e0d1c42391 | ||
|
|
1b9c3050aa | ||
|
|
5ba0afdd24 | ||
|
|
a8eb4ce47a | ||
|
|
feba6dfeb0 | ||
|
|
061f4a0ed0 | ||
|
|
f7c273a4f3 | ||
|
|
096dae3cd0 | ||
|
|
eebf51f4a6 | ||
|
|
000ae02d47 | ||
|
|
84c1ee3e88 | ||
|
|
0fea2076d9 | ||
|
|
b7a90b171f | ||
|
|
7a68f331b0 | ||
|
|
9e7abd5a85 | ||
|
|
9bc3f1137e | ||
|
|
0c7e3ac8b4 | ||
|
|
715f863167 | ||
|
|
e062301d42 | ||
|
|
77396558d7 | ||
|
|
d11c16c549 | ||
|
|
6a90ac40be | ||
|
|
25cc1d77b1 | ||
|
|
fd489d93bb | ||
|
|
e80487e379 | ||
|
|
3b4c2981ac | ||
|
|
348d45f2f5 | ||
|
|
4db96c7bdb | ||
|
|
b1617c6996 | ||
|
|
c078166e8d | ||
|
|
092936eda5 | ||
|
|
257a999300 | ||
|
|
df6a5040a5 | ||
|
|
3085ca2eeb | ||
|
|
fa4005cf9a | ||
|
|
c4cd375c04 | ||
|
|
19d9bb198f | ||
|
|
7016190ce5 | ||
|
|
8020ffa666 | ||
|
|
bc9914ffa6 | ||
|
|
4ded4ff470 | ||
|
|
7742299651 | ||
|
|
0b348a59d2 | ||
|
|
e51d54e346 | ||
|
|
e06b1f0177 | ||
|
|
178a870608 | ||
|
|
a7d3d7c651 | ||
|
|
c13024f1c7 | ||
|
|
c53c0ef727 | ||
|
|
cd93fe2646 | ||
|
|
7b506058a4 | ||
|
|
f658f74f35 | ||
|
|
eae5efa69d | ||
|
|
4e6051085e | ||
|
|
f174ccfc04 | ||
|
|
e1252d0554 | ||
|
|
afe638fb90 | ||
|
|
17699ce889 | ||
|
|
4afa06b7b9 | ||
|
|
2d02815aba | ||
|
|
ae1824c6a2 | ||
|
|
d63cc39478 | ||
|
|
51d6775429 | ||
|
|
35d92f1386 | ||
|
|
ba0ef6dccc | ||
|
|
2a325e6700 | ||
|
|
4e7566f06d | ||
|
|
d95bf4b71d | ||
|
|
43ddf6ccc4 | ||
|
|
b55da640e1 | ||
|
|
90534c3c9f | ||
|
|
88c9e5e902 | ||
|
|
ac41117a78 | ||
|
|
2418c5f625 | ||
|
|
52156aa9ff | ||
|
|
6010fb69a4 | ||
|
|
d5abbb3584 | ||
|
|
8c27504ee2 | ||
|
|
8a20525da3 | ||
|
|
c7b8326b52 | ||
|
|
ee8b80651e | ||
|
|
589676c52a | ||
|
|
83085d5f9a | ||
|
|
a1a204d0d5 | ||
|
|
9a43c36758 | ||
|
|
e017efe35f | ||
|
|
1645c17ab7 | ||
|
|
075b800932 | ||
|
|
56f9138db4 | ||
|
|
20c1b7acdb | ||
|
|
a10022d797 | ||
|
|
aafbbbec38 | ||
|
|
028a994070 | ||
|
|
581125db62 | ||
|
|
295770002a | ||
|
|
1a66d84bd0 | ||
|
|
cd3f51b66d | ||
|
|
6d8ac4c3da | ||
|
|
156382eaad | ||
|
|
2c155fe599 | ||
|
|
323de69ba0 | ||
|
|
0cdc6be34f | ||
|
|
36c1703763 | ||
|
|
ee3d2f681c | ||
|
|
11ce900868 | ||
|
|
b936dd2533 | ||
|
|
fab8cd0c1f | ||
|
|
ec3a962ec8 | ||
|
|
348813109b | ||
|
|
59598094df | ||
|
|
8b6ae59eda | ||
|
|
b3d8653882 | ||
|
|
719d1ebbb8 | ||
|
|
a23f902e9a | ||
|
|
79559635e1 | ||
|
|
c57a4e4132 | ||
|
|
c6f7d9d869 | ||
|
|
9fcddd06e4 | ||
|
|
3526a2b0e9 | ||
|
|
56187aa4dc | ||
|
|
9e698f382d | ||
|
|
d2583a6d96 | ||
|
|
250e4fddd9 | ||
|
|
2f274d969e | ||
|
|
2d02eda9e3 | ||
|
|
f26a9518e0 | ||
|
|
2b92bcf0a9 | ||
|
|
0ce6296318 | ||
|
|
2c90ceb5ca | ||
|
|
2fde327195 | ||
|
|
3aee440f67 | ||
|
|
290c1777b3 | ||
|
|
e0c8b64c65 | ||
|
|
810d339b4e | ||
|
|
53ce1e6934 | ||
|
|
ae4d3b2d65 | ||
|
|
1cdd51de21 | ||
|
|
9cf4702f69 | ||
|
|
cb5455c990 | ||
|
|
db86fef717 | ||
|
|
8bffeb52a7 | ||
|
|
9d2bbd164b | ||
|
|
a045766c7a | ||
|
|
34cff8c8cb | ||
|
|
03ec2af92e | ||
|
|
c2a8e27875 | ||
|
|
3fae5129d8 | ||
|
|
8311f7baaa | ||
|
|
17e39f94cc | ||
|
|
cd1e10c17c | ||
|
|
5defb428ff | ||
|
|
79a066e68a | ||
|
|
3e34bdf7fc | ||
|
|
bdf31231f0 | ||
|
|
17de611a22 | ||
|
|
4744191242 | ||
|
|
c81be6734a | ||
|
|
663fedb81b | ||
|
|
200d29dc1e | ||
|
|
41f6948f13 | ||
|
|
24e36b6283 | ||
|
|
1b1df14937 | ||
|
|
895f75a3c5 | ||
|
|
e428a8be57 | ||
|
|
a438d2dd88 | ||
|
|
f2107edaa3 | ||
|
|
644f93a46d | ||
|
|
dc52e0534e | ||
|
|
15e63eb86c | ||
|
|
842e59e893 | ||
|
|
2b7d6745ea | ||
|
|
3c41063a38 | ||
|
|
da8e84acad | ||
|
|
0b7658eb9b | ||
|
|
2d7bda3407 | ||
|
|
a7d11d70d4 | ||
|
|
4b25fb8363 | ||
|
|
b1aca0578c | ||
|
|
78d3021616 | ||
|
|
14a7a64af2 | ||
|
|
6156b30a36 | ||
|
|
231afd3e71 | ||
|
|
14803997ba | ||
|
|
9a99597d4e | ||
|
|
7079c3814b | ||
|
|
35a95e2b9b | ||
|
|
99d63c2396 | ||
|
|
62383ed31b | ||
|
|
a10f9f2ce8 | ||
|
|
924d3c4ea3 | ||
|
|
be83b53941 | ||
|
|
63657d5286 | ||
|
|
9bd670cfd3 | ||
|
|
2753804652 | ||
|
|
971c75e754 | ||
|
|
bbe29cbd72 | ||
|
|
f91deda5ba | ||
|
|
bd11a7fcc2 | ||
|
|
6edc8f06f3 | ||
|
|
7e3d170c83 | ||
|
|
cde3ea0c95 | ||
|
|
5a34fb3443 | ||
|
|
c000617928 | ||
|
|
01e4747483 | ||
|
|
77eb761fc3 | ||
|
|
64c95c1bc5 | ||
|
|
5e5f9ae4bc | ||
|
|
015059321a | ||
|
|
cc6ae217a4 | ||
|
|
a1c730caac | ||
|
|
365f6936a3 | ||
|
|
a6f2034377 | ||
|
|
95631ef468 | ||
|
|
5dc02c0bd5 | ||
|
|
ea43837e62 | ||
|
|
4c359e50f8 | ||
|
|
1feca411b6 | ||
|
|
4dfcf96897 | ||
|
|
90568f2216 | ||
|
|
4440763f44 | ||
|
|
5b80fee02e | ||
|
|
e21a0eca5c | ||
|
|
83a1096c08 | ||
|
|
59f1e7f471 | ||
|
|
12374fb573 | ||
|
|
734bffd279 | ||
|
|
478d6c9035 | ||
|
|
a6048c84bd | ||
|
|
c0ab14e241 | ||
|
|
23332bf4a6 | ||
|
|
a55293448e | ||
|
|
f809d724a0 | ||
|
|
32e2e91013 | ||
|
|
5b8d3a2824 | ||
|
|
d4b152e7c0 | ||
|
|
c62c84ae4d | ||
|
|
9e1869edde | ||
|
|
1a164ff8f1 | ||
|
|
6b3a4aec16 | ||
|
|
150a687f15 | ||
|
|
fc1d514475 | ||
|
|
5528bef09a | ||
|
|
4013740429 | ||
|
|
000a932ff1 | ||
|
|
60cd7728e6 | ||
|
|
4f37088e20 | ||
|
|
d7dc6487d0 | ||
|
|
61c9dcbd1d | ||
|
|
8f0404242d | ||
|
|
286af0e5bf | ||
|
|
6c49904bf8 | ||
|
|
509ee08e8e | ||
|
|
fdae6f52cc | ||
|
|
52ef7cd956 | ||
|
|
d8bd81a0b6 | ||
|
|
c2e33c7f50 | ||
|
|
a9498a1b92 | ||
|
|
f74b8373e3 | ||
|
|
809f628bc0 | ||
|
|
54a2b28219 | ||
|
|
0719c53e86 | ||
|
|
e9f3deaec0 | ||
|
|
8207be5940 | ||
|
|
c2e53cc8b9 | ||
|
|
251b99f76d | ||
|
|
c4c86104b5 | ||
|
|
857b1d9b57 | ||
|
|
277deace47 | ||
|
|
30c659110f | ||
|
|
a8b6adfba2 | ||
|
|
1548e16fbe | ||
|
|
0e34baf1bf | ||
|
|
2c461b5ccf | ||
|
|
2adbeddde3 | ||
|
|
e0a79fcc63 | ||
|
|
5e07813a57 | ||
|
|
400d478bca | ||
|
|
786ea2ca37 | ||
|
|
ba169d35a4 | ||
|
|
f2dbb31951 | ||
|
|
fe371f1d66 | ||
|
|
1b8ae87b08 | ||
|
|
fece5f64ce | ||
|
|
bcedbd85ef | ||
|
|
dc4ea5e96d | ||
|
|
7e0e01295f | ||
|
|
88268a22a9 | ||
|
|
428a1cb0d2 | ||
|
|
b3dda2a1ff | ||
|
|
74f9330c38 | ||
|
|
ebf99eda55 | ||
|
|
7d05e5382a | ||
|
|
e559520df3 | ||
|
|
a9b300d357 | ||
|
|
f58f930446 | ||
|
|
f10bf6d714 | ||
|
|
6898300c30 | ||
|
|
85bc6fdd28 | ||
|
|
4cdf5a8ba6 | ||
|
|
8ca220e98a | ||
|
|
ec2d37b295 | ||
|
|
3b2118331b | ||
|
|
ed89e504b0 | ||
|
|
30eec80177 | ||
|
|
07f730aba6 | ||
|
|
3bfd282614 | ||
|
|
d031954976 | ||
|
|
86f9b9d0f7 | ||
|
|
3b1f98d675 | ||
|
|
66e37c8f6b | ||
|
|
3fc6adb2b7 | ||
|
|
1c971c161f | ||
|
|
8883d048a7 | ||
|
|
66dbbb1eee | ||
|
|
cb7bbeba99 | ||
|
|
7bf8de139f | ||
|
|
b7ffc255f5 | ||
|
|
06aa4c60c8 | ||
|
|
07e4db9240 | ||
|
|
c0144809c6 | ||
|
|
ffeadc9e44 | ||
|
|
a15ed69624 | ||
|
|
b0fd40444f | ||
|
|
8cddc0d974 | ||
|
|
08b76b643d | ||
|
|
13c623b788 | ||
|
|
b77bd6b3f9 | ||
|
|
d3dec6981a | ||
|
|
5dd617be69 | ||
|
|
1b093469bf | ||
|
|
7286c81885 | ||
|
|
4ef7707736 | ||
|
|
c06a9f915d | ||
|
|
fe2907974a | ||
|
|
3e79617fc9 | ||
|
|
52e2fe6fed | ||
|
|
8e4265a5f8 | ||
|
|
623de00376 | ||
|
|
6320900d7c | ||
|
|
ab1ff284ca | ||
|
|
37ad3d0882 | ||
|
|
c94123da06 | ||
|
|
7eef1727a2 | ||
|
|
6f692db069 | ||
|
|
aefca644e8 | ||
|
|
99ff71b7e1 | ||
|
|
430ee19dd3 | ||
|
|
d9dbafb51b | ||
|
|
d78b3a7fa0 | ||
|
|
424dc6dd49 | ||
|
|
1f4347a316 | ||
|
|
acd9f2ae1c | ||
|
|
d753c36195 | ||
|
|
7dd0deb931 | ||
|
|
ba1126db0e | ||
|
|
359a417692 | ||
|
|
42ac0055c2 | ||
|
|
c0dd03e3f7 | ||
|
|
6b1bda629f | ||
|
|
0b43daf507 | ||
|
|
894038280e | ||
|
|
2704ab9afb | ||
|
|
8d604848da | ||
|
|
6f6f123880 | ||
|
|
620b037a17 | ||
|
|
a653454e7d | ||
|
|
38a8c9eb34 | ||
|
|
8cdf16a272 | ||
|
|
24333a24ae | ||
|
|
e83c542886 | ||
|
|
c61824b362 | ||
|
|
591f918412 | ||
|
|
ab1335102a | ||
|
|
41cdc33432 | ||
|
|
ef2cfec1a2 | ||
|
|
6f2a5d8cdb | ||
|
|
c3d52d430a | ||
|
|
806897adf8 | ||
|
|
c9b1bc175a | ||
|
|
f4d9d9795a | ||
|
|
08e6d670de | ||
|
|
a2848d52fc | ||
|
|
fa219a674e | ||
|
|
0d0faa3218 | ||
|
|
09ab930c56 | ||
|
|
9b7b411825 | ||
|
|
aacde8871e | ||
|
|
1aa1addcf7 | ||
|
|
a305980a78 | ||
|
|
0def055e8b | ||
|
|
29e9291030 | ||
|
|
e9015d95e4 | ||
|
|
9cedcc4395 | ||
|
|
744d8f4262 | ||
|
|
d9efc7aa19 | ||
|
|
53525cffe9 | ||
|
|
c1269ede63 | ||
|
|
6102a33397 | ||
|
|
192e8ef664 | ||
|
|
5778230ee4 | ||
|
|
9e959306b1 | ||
|
|
7a7f4eb458 | ||
|
|
050545411d | ||
|
|
28b394d5ea | ||
|
|
a3761dab35 | ||
|
|
79ae51a973 | ||
|
|
b724286886 | ||
|
|
9c52e789b3 | ||
|
|
717a3740c6 | ||
|
|
a8738b9ca9 | ||
|
|
ea5dba02b1 | ||
|
|
9fbcb59f32 | ||
|
|
9b312d4a71 | ||
|
|
480002e50d | ||
|
|
9cc775f548 | ||
|
|
514e50da74 | ||
|
|
9dea931db0 | ||
|
|
12c60715d2 | ||
|
|
23e0c45a52 | ||
|
|
c0c9c834e7 | ||
|
|
9aaa0e8d36 | ||
|
|
7bb4daabe2 | ||
|
|
b0203874af | ||
|
|
2ee757e0b2 | ||
|
|
b4db2885aa | ||
|
|
4381237eb3 | ||
|
|
d91d333baa | ||
|
|
5f6f979bce | ||
|
|
881b76fa3e | ||
|
|
e92dc29896 | ||
|
|
07e64a30e4 | ||
|
|
ac8765655f | ||
|
|
b5c8cfd9f8 | ||
|
|
71b11a3708 | ||
|
|
8040394150 | ||
|
|
a07856a877 | ||
|
|
6e990d3359 | ||
|
|
031cdbf187 | ||
|
|
7198bda02e | ||
|
|
18df20b675 | ||
|
|
581e6d2e5c | ||
|
|
91c92f9dac | ||
|
|
886e84a0c5 | ||
|
|
bfeeb955f0 | ||
|
|
beebffa5fc | ||
|
|
bf6085bdf9 | ||
|
|
db064e788b | ||
|
|
23be3be21e | ||
|
|
02ab809c34 | ||
|
|
4d538714e4 | ||
|
|
43e968864d | ||
|
|
03f01bb5a0 | ||
|
|
a7a6f60ebf | ||
|
|
4debd0a292 | ||
|
|
f0fa010df4 | ||
|
|
005112043e | ||
|
|
1d118af0f5 | ||
|
|
fa0bfc256a | ||
|
|
602905ef25 | ||
|
|
1f429e4773 | ||
|
|
6ce6699355 | ||
|
|
3d46ae8440 | ||
|
|
bbbea1db9c | ||
|
|
dc7f353e67 | ||
|
|
6ca6f14ad9 | ||
|
|
03fd2708cd | ||
|
|
6149497885 | ||
|
|
f810ef03a7 | ||
|
|
eeef444df7 | ||
|
|
03769e6cf6 | ||
|
|
b0bd759035 | ||
|
|
eb92b9ca78 | ||
|
|
411ab8db38 | ||
|
|
a679c17c64 | ||
|
|
87b7c46eb5 | ||
|
|
a8c0a05c28 | ||
|
|
6ac512e749 | ||
|
|
be55f3cf90 | ||
|
|
cbbbc7f211 | ||
|
|
623c4c3e39 | ||
|
|
b0ab1f0f2d | ||
|
|
733937ec74 | ||
|
|
14a8818563 | ||
|
|
695d00506b | ||
|
|
e1a90a79ec | ||
|
|
c6cedf7a02 | ||
|
|
9d870a3781 | ||
|
|
e0a33f3013 | ||
|
|
e3e95788b4 | ||
|
|
0e87595692 | ||
|
|
e5361949df | ||
|
|
62290e2812 | ||
|
|
489b82e8f4 | ||
|
|
4e8d6e058b | ||
|
|
535e3abe92 | ||
|
|
35fbd4443e | ||
|
|
edf667f462 | ||
|
|
a22be909a7 | ||
|
|
915f6d0b46 | ||
|
|
c23e7af24b | ||
|
|
91fdbaff4d | ||
|
|
c6f258f01d | ||
|
|
8811f92aea | ||
|
|
0726505c25 | ||
|
|
4df1d6cb0a | ||
|
|
773b5d4dab | ||
|
|
660fa66df4 | ||
|
|
c2992e3a35 | ||
|
|
d07649bcf8 | ||
|
|
5e1baf2104 | ||
|
|
e8701b65c6 | ||
|
|
517d6f6d0b | ||
|
|
b7c5c15280 | ||
|
|
0561a9bcf6 | ||
|
|
05a14a3610 | ||
|
|
b3737448d7 | ||
|
|
bfa91bf2fb | ||
|
|
b7948f7965 | ||
|
|
b0f310b2fe | ||
|
|
ac598fdfab | ||
|
|
60e6a04d0d | ||
|
|
c4884022af | ||
|
|
a70091bf99 | ||
|
|
ebaf75df95 | ||
|
|
2f044a17e1 | ||
|
|
782dafca33 | ||
|
|
7174bbc0bd | ||
|
|
f177045e89 | ||
|
|
e32d66e0aa | ||
|
|
0237aa31cb | ||
|
|
18921f8750 | ||
|
|
e848360c80 | ||
|
|
a075a7cba8 | ||
|
|
f98630cff1 | ||
|
|
325e756e4c | ||
|
|
556c623e98 | ||
|
|
de5d74a1f0 | ||
|
|
41decf85c9 | ||
|
|
51fa017b8b | ||
|
|
e78dd6f9dd | ||
|
|
0e8fcdd4d1 | ||
|
|
717fc422ff | ||
|
|
b07c4b7e6c | ||
|
|
ff2b4cf6e1 | ||
|
|
a131fa3dd7 | ||
|
|
b3a212c7fd | ||
|
|
0e608d169e | ||
|
|
c38fa2a70f | ||
|
|
4c54c4653e | ||
|
|
5ae5943cc8 | ||
|
|
857d367d15 | ||
|
|
8c766115db | ||
|
|
7bd36dd274 | ||
|
|
bd246300e0 | ||
|
|
31be546e8e | ||
|
|
d57983032e | ||
|
|
53b3190bbf | ||
|
|
d924237b7e | ||
|
|
da29fc2f25 | ||
|
|
3bbf195870 | ||
|
|
75dac72640 | ||
|
|
df9af9c35a | ||
|
|
cccc8e829e | ||
|
|
e0b1b18299 | ||
|
|
02d607817a | ||
|
|
8ae62e21a7 | ||
|
|
14a59262af | ||
|
|
12f46e2e4e | ||
|
|
dec2e6b69f | ||
|
|
e82327a979 | ||
|
|
81fb2ddcd1 | ||
|
|
a8922cd0b2 | ||
|
|
4f3ea48ec7 | ||
|
|
150265adf6 | ||
|
|
aff627c0cd | ||
|
|
2b803d4193 | ||
|
|
464fa7c0c9 | ||
|
|
e9564b1d33 | ||
|
|
c75929789d | ||
|
|
63b7f47f30 | ||
|
|
5650e6ccfe | ||
|
|
a0d3706497 | ||
|
|
0013ef42bd | ||
|
|
8da5f53856 | ||
|
|
d0c150d419 | ||
|
|
f52b828fe1 | ||
|
|
8babadc33e | ||
|
|
7047de4959 | ||
|
|
9b5e286b90 | ||
|
|
99d98b6227 | ||
|
|
03a343784d | ||
|
|
046350c0f9 | ||
|
|
a1f267916e | ||
|
|
a4b0c1fe36 | ||
|
|
6aa0878480 | ||
|
|
897a56fa4a | ||
|
|
86a1e405b9 | ||
|
|
e33fdaf74d | ||
|
|
a554982f08 | ||
|
|
553fa9245c | ||
|
|
f862013c6a | ||
|
|
7dd87c1ff6 | ||
|
|
900da46090 | ||
|
|
7d54b78521 | ||
|
|
ccc7d467c2 | ||
|
|
8b6c358266 | ||
|
|
444388e432 | ||
|
|
b4c00d866c | ||
|
|
b9606fcb49 | ||
|
|
ab6dfa0f08 | ||
|
|
81ee3c47f5 | ||
|
|
7a59f821cf | ||
|
|
6f7276bcdf | ||
|
|
2cd884bd9d | ||
|
|
40f4554373 | ||
|
|
76321d3491 | ||
|
|
c3b5db18a7 | ||
|
|
71afc90b0e | ||
|
|
c03d08b9f4 | ||
|
|
b9d8931f3e | ||
|
|
ec8bd2fc14 | ||
|
|
dd1bbf1226 | ||
|
|
c2c86ff58e | ||
|
|
2d2840c1bb | ||
|
|
571969fca9 | ||
|
|
16fa52790c | ||
|
|
c88c252587 | ||
|
|
253cb03de8 | ||
|
|
0b74adc13b | ||
|
|
6fc2328475 | ||
|
|
e9c4201acc | ||
|
|
9edb52c909 | ||
|
|
f4c7320bb5 | ||
|
|
17581ca204 | ||
|
|
11edb8ce5d | ||
|
|
40caa7b1eb | ||
|
|
e3f4c24983 | ||
|
|
dd0482cb26 | ||
|
|
4052007de8 | ||
|
|
85b03d9d8a | ||
|
|
36c3fd6f5d | ||
|
|
d397a5d35e | ||
|
|
43d83ad919 | ||
|
|
e71dd1d576 | ||
|
|
72e28b8e04 | ||
|
|
3432a15c62 | ||
|
|
45a5bae8b2 | ||
|
|
a566393a89 | ||
|
|
5f2f3f9122 | ||
|
|
c7fccf7e70 | ||
|
|
3d92d061f1 | ||
|
|
f2759675a1 | ||
|
|
2e6ff78662 | ||
|
|
8ca0a32932 | ||
|
|
72fbcd4e0e | ||
|
|
a5719a9c1d | ||
|
|
bad41d5027 | ||
|
|
3bfb95f190 | ||
|
|
f04695feb6 | ||
|
|
94da911f93 | ||
|
|
f4771d4ddb | ||
|
|
9f7c4adb0f | ||
|
|
6fb20fc6c1 | ||
|
|
ce8e91aab8 |
No files matched your search
@@ -1,5 +1,7 @@
|
||||
[*.{kt,kts}]
|
||||
ij_kotlin_imports_layout = *
|
||||
ij_kotlin_line_break_after_multiline_when_entry = false
|
||||
ktlint_code_style = intellij_idea
|
||||
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||
ktlint_standard_class-signature = disabled
|
||||
ktlint_standard_mixed-condition-operators = disabled
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,62 @@
|
||||
name: Baseline profile generation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 0 * * *'
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'benchmark/**'
|
||||
- '.github/workflows/baseline-profile.yml'
|
||||
|
||||
jobs:
|
||||
baseline-profile:
|
||||
runs-on: nekomoee
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .java-version
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: https://github.com/android-actions/setup-android@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly-2026-10-07
|
||||
targets: aarch64-linux-android, thumbv7neon-linux-androideabi
|
||||
|
||||
- name: Rust Cache
|
||||
uses: https://github.com/Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Generate Baseline Profile
|
||||
run: ./gradlew generateBaselineProfile
|
||||
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
|
||||
|
||||
- name: Upload reports
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: reports
|
||||
path: benchmark/build/reports
|
||||
@@ -0,0 +1,128 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: nekomoee
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .java-version
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: https://github.com/android-actions/setup-android@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly-2026-10-07
|
||||
targets: aarch64-linux-android, thumbv7neon-linux-androideabi
|
||||
|
||||
- name: Rust Cache
|
||||
uses: https://github.com/Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
shared-key: ${{ runner.os }}
|
||||
|
||||
- name: Rustfmt Check
|
||||
working-directory: ./app/src/main/rust
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy Check
|
||||
working-directory: ./app/src/main/rust
|
||||
run: cargo clippy --target aarch64-linux-android --all-features -- -D warnings
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Check build-logic
|
||||
run: ./gradlew :build-logic:convention:check
|
||||
|
||||
- name: Spotless Check
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
default:
|
||||
runs-on: nekomoee
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .java-version
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: https://github.com/android-actions/setup-android@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly-2026-10-07
|
||||
targets: aarch64-linux-android, thumbv7neon-linux-androideabi
|
||||
|
||||
- name: Rust Cache
|
||||
uses: https://github.com/Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
shared-key: ${{ runner.os }}
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-default-${{ hashFiles('rust-toolchain.toml', 'settings.gradle.kts', 'app/src/main/cpp/CMakeLists.txt', 'app/src/main/rust/Cargo.lock') }}
|
||||
restore-keys: cmake-default-
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew assembleDefaultRelease
|
||||
|
||||
- name: Upload ARM64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk
|
||||
|
||||
- name: Upload x86_64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-x86_64-release.apk
|
||||
|
||||
- name: Upload ARM32
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload mapping
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/defaultRelease/*.txt
|
||||
|
||||
- name: Upload native debug symbols
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-native-debug-symbols-${{ github.sha }}
|
||||
path: app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Releases
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: nekomoee
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: rm -rf $AGENT_TOOLSDIRECTORY
|
||||
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version-file: .java-version
|
||||
|
||||
- name: Setup Rust
|
||||
uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly-2026-10-07
|
||||
targets: aarch64-linux-android, thumbv7neon-linux-androideabi
|
||||
|
||||
- name: Rust Cache
|
||||
uses: https://github.com/Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Generate Baseline Profile
|
||||
run: ./gradlew generateBaselineProfile
|
||||
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
|
||||
-Prelease
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew assembleRelease
|
||||
-Prelease
|
||||
|
||||
- name: Rename Apks
|
||||
run: |
|
||||
mkdir release
|
||||
mv app/build/outputs/apk/default/release/app-default-universal-release.apk release/EhViewer-${{ github.ref_name }}-default-universal.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk release/EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-x86_64-release.apk release/EhViewer-${{ github.ref_name }}-default-x86_64.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk release/EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/defaultRelease/mapping.txt release/EhViewer-${{ github.ref_name }}-default-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip release/EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
|
||||
|
||||
- name: Releases
|
||||
uses: actions/forgejo-release@v2
|
||||
with:
|
||||
direction: upload
|
||||
url: https://codeberg.org
|
||||
repo: EhViewer/EhViewer
|
||||
release-dir: release
|
||||
body: Bump Version
|
||||
@@ -1 +0,0 @@
|
||||
*.sh eol=lf
|
||||
@@ -1,185 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
shared-key: ${{ runner.os }}
|
||||
|
||||
- name: Rustfmt Check
|
||||
working-directory: ./app/src/main/rust
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy Check
|
||||
working-directory: ./app/src/main/rust
|
||||
run: cargo clippy --target aarch64-linux-android --all-features -- -D warnings
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Spotless Check
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
- name: Lint Check
|
||||
run: ./gradlew lintMarshmallowRelease
|
||||
|
||||
default:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
shared-key: ${{ runner.os }}
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-default-${{ hashFiles('settings.gradle.kts', 'app/src/main/cpp/CMakeLists.txt', 'app/src/main/rust/Cargo.lock') }}
|
||||
restore-keys: cmake-default-
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew assembleDefaultRelease
|
||||
|
||||
- name: Upload ARM64
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: default-arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk
|
||||
|
||||
- name: Upload x86_64
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: default-x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-x86_64-release.apk
|
||||
|
||||
- name: Upload ARM32
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: default-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload mapping
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: default-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/defaultRelease
|
||||
|
||||
- name: Upload native debug symbols
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: default-native-debug-symbols-${{ github.sha }}
|
||||
path: app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip
|
||||
|
||||
marshmallow:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
shared-key: ${{ runner.os }}
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-marshmallow-${{ hashFiles('settings.gradle.kts', 'app/src/main/cpp/CMakeLists.txt', 'app/src/main/rust/Cargo.lock') }}
|
||||
restore-keys: cmake-marshmallow-
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew assembleMarshmallowRelease
|
||||
|
||||
- name: Upload ARM64
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: marshmallow-arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-arm64-v8a-release.apk
|
||||
|
||||
- name: Upload x86_64
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: marshmallow-x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-x86_64-release.apk
|
||||
|
||||
- name: Upload ARM32
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: marshmallow-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload mapping
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: marshmallow-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/marshmallowRelease
|
||||
|
||||
- name: Upload native debug symbols
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: marshmallow-native-debug-symbols-${{ github.sha }}
|
||||
path: app/build/outputs/native-debug-symbols/marshmallowRelease/native-debug-symbols.zip
|
||||
@@ -1,21 +0,0 @@
|
||||
name: 'Lock Threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: lock-threads
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
issue-inactive-days: '1'
|
||||
process-only: 'issues'
|
||||
@@ -1,90 +0,0 @@
|
||||
name: Releases
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: rm -rf $AGENT_TOOLSDIRECTORY
|
||||
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-
|
||||
restore-keys: cmake-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
|
||||
- name: Generate Baseline Profile
|
||||
run: ./gradlew generateBaselineProfile
|
||||
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Rename Apks
|
||||
run: |
|
||||
mv app/build/outputs/apk/default/release/app-default-universal-release.apk EhViewer-${{ github.ref_name }}-default-universal.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-x86_64.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/defaultRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
|
||||
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/marshmallowRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/marshmallowRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
|
||||
|
||||
- name: Releases
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: Bump Version
|
||||
files: |
|
||||
EhViewer-${{ github.ref_name }}-default-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
|
||||
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
|
||||
+4
-2
@@ -1,8 +1,10 @@
|
||||
.gradle
|
||||
/local.properties
|
||||
.DS_Store
|
||||
/build
|
||||
/.kotlin
|
||||
build
|
||||
.cxx
|
||||
.kotlin
|
||||
*.iml
|
||||
.idea
|
||||
.vscode
|
||||
/captures
|
||||
@@ -0,0 +1 @@
|
||||
25
|
||||
@@ -1,5 +1,162 @@
|
||||
# 变更日志
|
||||
|
||||
## [1.14.6] - 2025-12-17
|
||||
|
||||
### 新功能
|
||||
|
||||
* 支持显示画廊阅读进度(于 EH 设置中开启) [#2277](https://github.com/FooIbar/EhViewer/issues/2277)
|
||||
|
||||
### 改进
|
||||
|
||||
* 画廊列表变化时重新显示悬浮操作按钮 [#2681](https://github.com/FooIbar/EhViewer/issues/2681)
|
||||
* 移除文件名中的尾随空格和点以兼容 Windows [#2727](https://github.com/FooIbar/EhViewer/issues/2727)
|
||||
* 长按画廊详情中的标签时显示标签名称 [#2769](https://github.com/FooIbar/EhViewer/issues/2769)
|
||||
* 杂项优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 对话框中的下拉菜单显示错位
|
||||
* HTTP 状态错误显示为“什么都没有找到”
|
||||
* 某些老设备无法通过 Cloudflare 验证 [#2794](https://github.com/FooIbar/EhViewer/issues/2794)
|
||||
* 杂项修复
|
||||
|
||||
|
||||
## [1.14.5] - 2025-10-19
|
||||
|
||||
### 改进
|
||||
|
||||
* [阅读器] 显示画廊标题
|
||||
* [阅读器] 显示菜单时改变标题栏颜色
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 横屏模式下打开阅读器设置会闪退 [#2673](https://github.com/FooIbar/EhViewer/issues/2673)
|
||||
* 强制横屏时竖直拿取设备加载图片会横竖屏乱跳 [#2674](https://github.com/FooIbar/EhViewer/issues/2674)
|
||||
|
||||
|
||||
## [1.14.4] - 2025-10-12
|
||||
|
||||
### 改进
|
||||
|
||||
* [阅读器] 支持禁用使用音量键翻页 [#2593](https://github.com/FooIbar/EhViewer/issues/2593)
|
||||
* 视搜索警告为错误 [#2604](https://github.com/FooIbar/EhViewer/issues/2604)
|
||||
* 余额不足时停止下载并显示通知 [#2479](https://github.com/FooIbar/EhViewer/issues/2479)
|
||||
* [阅读器] 使用默认阅读模式时对带有 webtoon 标签的画廊自动使用条漫模式 [#2632](https://github.com/FooIbar/EhViewer/issues/2632)
|
||||
* 优化按页数排序下载项目的速度
|
||||
* 开启显示标签投票状态时为 power >= 100 的标签添加下划线
|
||||
* 杂项优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* Android 16 QPR1 上画廊详情中的标签文本对比度不足
|
||||
* 初始化 DownloadManager 用时过长导致 ANR [#2588](https://github.com/FooIbar/EhViewer/issues/2588)
|
||||
* 某些设备上无法下载名称较长的画廊 [#2607](https://github.com/FooIbar/EhViewer/issues/2607)
|
||||
* 评论区链接实际可点击区域比画面显示上的略长 [#2615](https://github.com/FooIbar/EhViewer/issues/2615)
|
||||
* 主页快速搜索再次打开出现以前的画廊重复出现在最上层 [#2628](https://github.com/FooIbar/EhViewer/issues/2628)
|
||||
* 非条漫模式下原始大小较小的动图不播放 [#2652](https://github.com/FooIbar/EhViewer/issues/2652)
|
||||
* Android 9 以下打开评论区切屏后回来会闪退 [#2655](https://github.com/FooIbar/EhViewer/issues/2655)
|
||||
* 杂项修复
|
||||
|
||||
|
||||
## [1.14.3] - 2025-09-01
|
||||
|
||||
### 新功能
|
||||
|
||||
* 支持以 label: 语法搜索下载标签
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* [阅读器] 关闭滤镜设置时崩溃 [#2573](https://github.com/FooIbar/EhViewer/issues/2573)
|
||||
* 编辑评论时上下文菜单缺少格式化选项
|
||||
* 某些情况下搜索相似画廊时出错
|
||||
|
||||
|
||||
## [1.14.2] - 2025-08-16
|
||||
|
||||
### 改进
|
||||
|
||||
* [下载] 支持将最低下载速度设为 0 [#2545](https://github.com/FooIbar/EhViewer/issues/2545)
|
||||
* [下载] 移除令人困惑的连接超时设置 [#2556](https://github.com/FooIbar/EhViewer/issues/2556)
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 下载速度计算不准确导致下载失败误报 [#2545](https://github.com/FooIbar/EhViewer/issues/2545)
|
||||
* 无法解析带有 location: 标签的画廊
|
||||
|
||||
|
||||
## [1.14.1] - 2025-07-31
|
||||
|
||||
### 改进
|
||||
|
||||
* 更新 GitHub 访问令牌
|
||||
|
||||
|
||||
## [1.14.0] - 2025-07-23
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 默认隐藏分数为 -100 的评论
|
||||
* 迁移部分组件到 Material 3 Expressive
|
||||
|
||||
### 新功能
|
||||
|
||||
* [EH] 支持显示标签投票状态
|
||||
* 支持添加画廊标签时自动补全
|
||||
* [下载] 支持指定连接超时和最低响应速度 [#1915](https://github.com/FooIbar/EhViewer/issues/1915)
|
||||
* [阅读器] 支持反转物理按键控制 [#1962](https://github.com/FooIbar/EhViewer/issues/1962)
|
||||
* [高级] 支持禁用 QUIC 支持
|
||||
* [高级] 重新添加桌面版网站选项以绕过 Cloudflare [#2230](https://github.com/FooIbar/EhViewer/issues/2230)
|
||||
* 支持 Android 6.0 上的动画 WebP
|
||||
|
||||
### 改进
|
||||
|
||||
* 优化压缩包加载性能
|
||||
* 优化预加载策略
|
||||
* 更新日本语翻译
|
||||
* 为已选中的项目使用不同的颜色 [#2121](https://github.com/FooIbar/EhViewer/issues/2121)
|
||||
* 缩略图模式下显示已收藏图标 [#2128](https://github.com/FooIbar/EhViewer/issues/2128)
|
||||
* 迁移大部分解析器到 Rust 实现
|
||||
* 优化动画 WebP 解码性能 [#2415](https://github.com/FooIbar/EhViewer/issues/2415)
|
||||
* 优化标签自动补全命中率 [#2473](https://github.com/FooIbar/EhViewer/issues/2473)
|
||||
* 在状态栏后绘制半透明背景以提高对比度
|
||||
* 杂项优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 退出阅读器时崩溃
|
||||
* Android 8.0 以下的设备无法读取某些压缩包
|
||||
* 列表模式下点击画廊封面无法进入详情页
|
||||
* 点击某些评论时崩溃
|
||||
* 开启裁剪边缘时某些图片被不正确裁剪
|
||||
* 下载数较多时无网络时启动 app 时崩溃 [#2041](https://github.com/FooIbar/EhViewer/issues/2041)
|
||||
* 标签翻译数据无法更新 [#2075](https://github.com/FooIbar/EhViewer/issues/2075)
|
||||
* 画廊详情页面显示的收藏夹名称错误 [#2081](https://github.com/FooIbar/EhViewer/issues/2081)
|
||||
* 不使用 CI 频道时无法更新
|
||||
* 添加后移动快捷搜索闪退 [#2124](https://github.com/FooIbar/EhViewer/issues/2124)
|
||||
* 某些数据无法导入
|
||||
* App 位于后台时清除下载通知时崩溃
|
||||
* 杂项修复
|
||||
|
||||
|
||||
## [1.13.1] - 2024-11-11
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 由于网站限制,移除搜索封面功能
|
||||
* 移除桌面版网站选项,默认使用 1280x 分辨率
|
||||
|
||||
### 改进
|
||||
|
||||
* 更新日本语翻译
|
||||
* 优化压缩包读取
|
||||
* 缓解某些小米系统上应用闪退 [#1826](https://github.com/FooIbar/EhViewer/issues/1826)
|
||||
* 杂项优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* Android 9 上的下载失败误报 [#1914](https://github.com/FooIbar/EhViewer/issues/1914)
|
||||
|
||||
|
||||
## [1.13.0] - 2024-11-01
|
||||
|
||||
### 重大变更
|
||||
|
||||
@@ -1,98 +1,25 @@
|
||||
<p align="right">
|
||||
<strong>English</strong>
|
||||
<span> | </span>
|
||||
<a href="/docs/README/zh-cn.md">
|
||||
简体中文
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="/docs/README/zh-tw.md">
|
||||
正體中文
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">
|
||||
<img src="https://github.com/Ehviewer-Overhauled/Art/blob/master/launcher_icon-web.svg" width="200" alt="EhViewer">
|
||||
<img src="https://github.com/FooIbar/EhViewer-art/blob/master/launcher_icon-web.svg" width="200" alt="EhViewer">
|
||||
<br>EhViewer<br>
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml/badge.svg" alt="Github Actions">
|
||||
</a>
|
||||
<a href="/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/FooIbar/EhViewer" alt="LICENSE">
|
||||
</a>
|
||||
<a href="https://www.codefactor.io/repository/github/FooIbar/EhViewer">
|
||||
<img src="https://www.codefactor.io/repository/github/FooIbar/EhViewer/badge" alt="CodeFactor">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img src="https://img.shields.io/github/v/release/FooIbar/EhViewer" alt="Release">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/issues">
|
||||
<img src="https://img.shields.io/github/issues/FooIbar/EhViewer" alt="Issues">
|
||||
</a>
|
||||
Standalone fork of
|
||||
<a href="https://codeberg.org/EhViewer/EhViewer">EhViewer (codeberg)</a>
|
||||
maintained at <a href="https://gitea.yrkaierx.com/yenru0/EhViewer">gitea.yrkaierx.com/yenru0/EhViewer</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<h3>
|
||||
<a href="#description">
|
||||
Description
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="#download">
|
||||
Download
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="#screenshot">
|
||||
Screenshot
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="#thanks">
|
||||
Thanks
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="#license">
|
||||
License
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
# Changes over upstream
|
||||
|
||||
# Description
|
||||
- Built-in hosts, Cloudflare CDN fallback and domain fronting with SNI spoofing for censored networks (`EhHostsConfig.kt`, `EhDns.kt`, `EhSSLSocketFactory.kt`)
|
||||
- Cronet engine disabled by default (bypasses the anti-DPI OkHttp stack; `enable_quic` re-enables it)
|
||||
- Update check disabled by default (manual check still available)
|
||||
- API request timeout raised to 30 s
|
||||
- Own application id (`yenru0.yrkaierx.ehviewer`), installs side by side with upstream builds
|
||||
|
||||
EhViewer fork dedicated to lightweight and high-performance
|
||||
# Upstream
|
||||
|
||||
with [Material Design 3](https://m3.material.io/)
|
||||
and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview) Support
|
||||
|
||||
# Download
|
||||
|
||||
| Flavor | Feature |
|
||||
|-------------|----------------------------------|
|
||||
| Default | Android 8.0+, full support |
|
||||
| Marshmallow | Android 6.0-7.1, limited support |
|
||||
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img alt="Get it on GitHub" src="https://github.com/Ehviewer-Overhauled/Art/blob/master/get-it-on-github.svg" width="200px"/>
|
||||
</a>
|
||||
|
||||
# Screenshot
|
||||
|
||||

|
||||

|
||||
|
||||
# Thanks
|
||||
|
||||
Here is the libraries
|
||||
|
||||
- [Arrow](https://arrow-kt.io/)
|
||||
- [AOSP & AndroidX](https://source.android.com/)
|
||||
- [Kotlin & KotlinX](https://kotlinlang.org/)
|
||||
- [Material Icons](https://github.com/google/material-design-icons)
|
||||
- [Ktor](https://ktor.io/)
|
||||
- [Coil](https://coil-kt.github.io/coil/)
|
||||
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
|
||||
- [libarchive](https://www.libarchive.org/)
|
||||
- [jsoup](https://jsoup.org/)
|
||||
Synced from [EhViewer/EhViewer on codeberg](https://codeberg.org/EhViewer/EhViewer), the continuation of FooIbar's EhViewer. Feature work happens there; this fork tracks it and carries the network/resilience patches above.
|
||||
|
||||
# License
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/build
|
||||
/.cxx
|
||||
+66
-108
@@ -1,44 +1,22 @@
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
|
||||
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val isRelease: Boolean
|
||||
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.ehviewer.android.application)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.spotless)
|
||||
alias(libs.plugins.aboutlibrariesPlugin)
|
||||
alias(libs.plugins.composeCompilerReportGenerator)
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
alias(libs.plugins.aboutlibrariesAndroid)
|
||||
alias(libs.plugins.baselineprofile)
|
||||
}
|
||||
|
||||
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
|
||||
|
||||
android {
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters += listOf(
|
||||
"zh",
|
||||
"zh-rCN",
|
||||
"zh-rHK",
|
||||
"zh-rTW",
|
||||
"es",
|
||||
"ja",
|
||||
"ko",
|
||||
"fr",
|
||||
"de",
|
||||
"th",
|
||||
"tr",
|
||||
"nb-rNO",
|
||||
)
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
@@ -71,40 +49,38 @@ android {
|
||||
|
||||
val repoName = providers.exec {
|
||||
commandLine = "git remote get-url origin".split(' ')
|
||||
}.standardOutput.asText.get().trim().removePrefix("https://github.com/").removePrefix("git@github.com:")
|
||||
.removeSuffix(".git")
|
||||
}.standardOutput.asText.get().trim().removeSuffix(".git").removePrefix("https://codeberg.org/")
|
||||
|
||||
val chromeVersion = rootProject.layout.projectDirectory.file("chrome.version").asFile.readText().trim()
|
||||
val snapshot = !hasProperty("release")
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
versionCode = 180061
|
||||
versionName = "1.13.0"
|
||||
// versionNameSuffix = "-SNAPSHOT"
|
||||
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
|
||||
applicationId = "yenru0.yrkaierx.ehviewer"
|
||||
versionCode = 180063
|
||||
versionName = if (snapshot) {
|
||||
"1.15.0-SNAPSHOT"
|
||||
} else {
|
||||
"1.14.6"
|
||||
}
|
||||
buildConfigField("boolean", "SNAPSHOT", "$snapshot")
|
||||
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
|
||||
buildConfigField("long", "COMMIT_TIME", commitTime)
|
||||
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
|
||||
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
|
||||
ndk {
|
||||
if (isRelease) {
|
||||
abiFilters.addAll(supportedAbis)
|
||||
}
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "api"
|
||||
|
||||
productFlavors {
|
||||
create("default")
|
||||
create("default") {
|
||||
minSdk = 26
|
||||
}
|
||||
create("marshmallow") {
|
||||
minSdk = 23
|
||||
applicationIdSuffix = ".m"
|
||||
versionNameSuffix = "-M"
|
||||
}
|
||||
@@ -120,16 +96,13 @@ android {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
disable += setOf("MissingTranslation", "MissingQuantity")
|
||||
error += setOf("InlinedApi")
|
||||
}
|
||||
|
||||
packaging {
|
||||
dex {
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
jniLibs {
|
||||
excludes += "**/libdatastore_shared_counter.so" // DataStore multi-process
|
||||
}
|
||||
resources {
|
||||
// Required by Layout Inspector
|
||||
pickFirsts += "/META-INF/androidx.compose.ui_ui.version"
|
||||
@@ -139,12 +112,32 @@ android {
|
||||
"/kotlin/**",
|
||||
"**.txt",
|
||||
"**.bin",
|
||||
"**.{html,mmd}", // Compose Destination
|
||||
"/okhttp3/**", // Okhttp public suffix
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
ignoreAssetsPatterns += listOf(
|
||||
"!PublicSuffixDatabase.list", // OkHttp
|
||||
"!composepreference.preference.generated.resources",
|
||||
)
|
||||
generateLocaleConfig = true
|
||||
localeFilters += listOf(
|
||||
"zh",
|
||||
"zh-rCN",
|
||||
"zh-rHK",
|
||||
"zh-rTW",
|
||||
"es",
|
||||
"ja",
|
||||
"ko",
|
||||
"fr",
|
||||
"de",
|
||||
"th",
|
||||
"tr",
|
||||
"nb-rNO",
|
||||
)
|
||||
}
|
||||
|
||||
dependenciesInfo.includeInApk = false
|
||||
|
||||
buildTypes {
|
||||
@@ -174,34 +167,34 @@ android {
|
||||
namespace = "com.hippo.ehviewer"
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
featureFlags = setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups)
|
||||
}
|
||||
|
||||
baselineProfile {
|
||||
mergeIntoMain = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.i18n)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/activity
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.browser)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/compose-material3
|
||||
api(platform(libs.compose.bom))
|
||||
implementation(libs.bundles.compose)
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
implementation(libs.compose.destinations.core)
|
||||
ksp(libs.compose.destinations.compiler)
|
||||
|
||||
implementation(libs.compose.preference) {
|
||||
// R8 won't remove it because it adds a content provider
|
||||
exclude(group = "org.jetbrains.compose.components", module = "components-resources")
|
||||
}
|
||||
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.graphics.path)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
@@ -211,7 +204,6 @@ dependencies {
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/room
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.room.paging)
|
||||
|
||||
implementation(libs.androidx.work.runtime)
|
||||
@@ -222,9 +214,6 @@ dependencies {
|
||||
|
||||
// https://square.github.io/okhttp/changelogs/changelog/
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.okhttp.android)
|
||||
|
||||
implementation(libs.okio.jvm)
|
||||
|
||||
implementation(libs.logcat)
|
||||
|
||||
@@ -237,8 +226,7 @@ dependencies {
|
||||
implementation(libs.reorderable)
|
||||
|
||||
implementation(platform(libs.arrow.stack))
|
||||
implementation(libs.arrow.fx.coroutines)
|
||||
implementation(libs.arrow.resilience)
|
||||
implementation(libs.bundles.arrow)
|
||||
|
||||
// https://coil-kt.github.io/coil/changelog/
|
||||
implementation(platform(libs.coil.bom))
|
||||
@@ -252,7 +240,6 @@ dependencies {
|
||||
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
coreLibraryDesugaring(libs.desugar)
|
||||
|
||||
@@ -264,55 +251,26 @@ dependencies {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
compilerOptions {
|
||||
freeCompilerArgs = listOf(
|
||||
// https://kotlinlang.org/docs/compiler-reference.html#progressive
|
||||
"-progressive",
|
||||
"-Xjvm-default=all",
|
||||
"-Xcontext-receivers",
|
||||
"-Xwhen-guards",
|
||||
"-Xsuppress-warning=CONTEXT_RECEIVERS_DEPRECATED",
|
||||
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
|
||||
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
|
||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalSharedTransitionApi",
|
||||
"-opt-in=androidx.paging.ExperimentalPagingApi",
|
||||
"-opt-in=kotlin.contracts.ExperimentalContracts",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.coroutines.FlowPreview",
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
|
||||
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
|
||||
optIn.addAll(
|
||||
"coil3.annotation.ExperimentalCoilApi",
|
||||
"androidx.paging.ExperimentalPagingApi",
|
||||
"me.saket.telephoto.ExperimentalTelephotoApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
arg("compose-destinations.codeGenPackageName", "com.hippo.ehviewer.ui")
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
duplicationMode = MERGE
|
||||
duplicationRule = GROUP
|
||||
}
|
||||
|
||||
val ktlintVersion = libs.ktlint.get().version
|
||||
|
||||
spotless {
|
||||
kotlin {
|
||||
// https://github.com/diffplug/spotless/issues/111
|
||||
target("src/**/*.kt")
|
||||
ktlint(ktlintVersion)
|
||||
collect {
|
||||
includePlatform = false
|
||||
}
|
||||
kotlinGradle {
|
||||
ktlint(ktlintVersion)
|
||||
library {
|
||||
exclusionPatterns.add(Pattern.compile("org\\.jetbrains\\.(?:compose|androidx)\\..*"))
|
||||
duplicationMode = DuplicateMode.MERGE
|
||||
duplicationRule = DuplicateRule.GROUP
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -11,6 +11,14 @@
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
-keepclassmembers,allowobfuscation class com.hippo.ehviewer.coil.AnimatedWebPDrawable {
|
||||
java.nio.ByteBuffer source;
|
||||
}
|
||||
|
||||
-assumevalues public class androidx.compose.runtime.ComposeRuntimeFlags {
|
||||
static boolean isLinkBufferComposerEnabled() return true;
|
||||
}
|
||||
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:banner="@mipmap/ic_leanback_banner"
|
||||
android:configChanges="screenSize"
|
||||
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppTheme.Splash"
|
||||
@@ -118,10 +118,6 @@
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.SecurityActivity"
|
||||
android:configChanges="screenSize"
|
||||
android:label="@string/settings" />
|
||||
<activity
|
||||
android:name=".shortcuts.ShortcutsActivity"
|
||||
android:excludeFromRecents="true"
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
From d1db03ebd9989d10a5a905b792b712c6487a0057 Mon Sep 17 00:00:00 2001
|
||||
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
|
||||
Date: Sat, 24 Feb 2024 12:03:15 +0000
|
||||
Subject: [PATCH] Don't build other binaries
|
||||
|
||||
---
|
||||
CMakeLists.txt | 8 ++++----
|
||||
1 file changed, 4 insertions(+), 4 deletions(-)
|
||||
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index a346dd02..52b5d0d8 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -1567,7 +1567,7 @@ endif()
|
||||
# xzdec and lzmadec
|
||||
#############################################################################
|
||||
|
||||
-if(HAVE_DECODERS AND (NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900))
|
||||
+if(0)
|
||||
foreach(XZDEC xzdec lzmadec)
|
||||
add_executable("${XZDEC}"
|
||||
src/common/sysdefs.h
|
||||
@@ -1624,7 +1624,7 @@ endif()
|
||||
# lzmainfo
|
||||
#############################################################################
|
||||
|
||||
-if(HAVE_DECODERS AND (NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900))
|
||||
+if(0)
|
||||
add_executable(lzmainfo
|
||||
src/common/sysdefs.h
|
||||
src/common/tuklib_common.h
|
||||
@@ -1680,7 +1680,7 @@ endif()
|
||||
# xz
|
||||
#############################################################################
|
||||
|
||||
-if(NOT MSVC OR MSVC_VERSION GREATER_EQUAL 1900)
|
||||
+if(0)
|
||||
add_executable(xz
|
||||
src/common/mythread.h
|
||||
src/common/sysdefs.h
|
||||
@@ -1914,7 +1914,7 @@ endif()
|
||||
# Scripts
|
||||
#############################################################################
|
||||
|
||||
-if(UNIX)
|
||||
+if(0)
|
||||
# NOTE: This isn't as sophisticated as in the Autotools build which
|
||||
# uses posix-shell.m4 but hopefully this doesn't need to be either.
|
||||
# CMake likely won't be used on as many (old) obscure systems as the
|
||||
--
|
||||
2.34.1
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
From e971b9f23727833cc39b9e325db30f383e5bfc30 Mon Sep 17 00:00:00 2001
|
||||
From: Dude so hot <djohn@fbi.gov>
|
||||
Date: Thu, 14 Nov 2024 00:28:13 +0800
|
||||
Subject: [PATCH] Fix zip_time performance
|
||||
|
||||
Signed-off-by: Dude so hot <djohn@fbi.gov>
|
||||
---
|
||||
libarchive/archive_time.c | 1 +
|
||||
1 file changed, 1 insertion(+)
|
||||
|
||||
diff --git a/libarchive/archive_time.c b/libarchive/archive_time.c
|
||||
index 3352c809..9289bf1e 100644
|
||||
--- a/libarchive/archive_time.c
|
||||
+++ b/libarchive/archive_time.c
|
||||
@@ -53,6 +53,7 @@ FILETIME_to_ntfs(const FILETIME* filetime)
|
||||
int64_t
|
||||
dos_to_unix(uint32_t dos_time)
|
||||
{
|
||||
+ return 0;
|
||||
uint16_t msTime, msDate;
|
||||
struct tm ts;
|
||||
time_t t;
|
||||
--
|
||||
2.47.0
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
From aee8f7147ddb262fd9e5feee8d5b17094cf3470f Mon Sep 17 00:00:00 2001
|
||||
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
|
||||
Date: Tue, 26 Sep 2023 00:09:25 +0800
|
||||
Subject: [PATCH] Insert link libs
|
||||
|
||||
---
|
||||
CMakeLists.txt | 2 +-
|
||||
1 file changed, 1 insertion(+), 1 deletion(-)
|
||||
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index ec97e4c7..420f204c 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
|
||||
# # endif
|
||||
ENDIF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
|
||||
|
||||
-SET(ADDITIONAL_LIBS "")
|
||||
+SET(ADDITIONAL_LIBS ${LIBARCHIVE_CUSTOM_LIBS})
|
||||
#
|
||||
# Find ZLIB
|
||||
#
|
||||
--
|
||||
2.34.1
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
From 86d199429b3fabc83f7dcc9afd72e7b4c7750b1a Mon Sep 17 00:00:00 2001
|
||||
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
|
||||
Date: Thu, 14 Nov 2024 20:22:52 +0800
|
||||
Subject: [PATCH] Use UTF-8 as default charset on bionic
|
||||
|
||||
---
|
||||
libarchive/archive_string.c | 4 +++-
|
||||
1 file changed, 3 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/libarchive/archive_string.c b/libarchive/archive_string.c
|
||||
index abf7ad66..63a73c58 100644
|
||||
--- a/libarchive/archive_string.c
|
||||
+++ b/libarchive/archive_string.c
|
||||
@@ -423,7 +423,9 @@ static const char *
|
||||
default_iconv_charset(const char *charset) {
|
||||
if (charset != NULL && charset[0] != '\0')
|
||||
return charset;
|
||||
-#if HAVE_LOCALE_CHARSET && !defined(__APPLE__)
|
||||
+#ifdef __BIONIC__
|
||||
+ return "UTF-8";
|
||||
+#elif HAVE_LOCALE_CHARSET && !defined(__APPLE__)
|
||||
/* locale_charset() is broken on Mac OS */
|
||||
return locale_charset();
|
||||
#elif HAVE_NL_LANGINFO
|
||||
--
|
||||
2.43.0
|
||||
|
||||
@@ -4,56 +4,67 @@ include(ExternalProject)
|
||||
include(FetchContent)
|
||||
|
||||
if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Ofast -fvisibility=hidden -fvisibility-inlines-hidden -funroll-loops -flto \
|
||||
-mllvm -polly \
|
||||
-mllvm -polly-run-dce \
|
||||
-mllvm -polly-run-inliner \
|
||||
-mllvm -polly-isl-arg=--no-schedule-serialize-sccs \
|
||||
-mllvm -polly-ast-use-context \
|
||||
-mllvm -polly-detect-keep-going \
|
||||
-mllvm -polly-position=before-vectorizer \
|
||||
-mllvm -polly-vectorizer=stripmine \
|
||||
-mllvm -polly-detect-profitability-min-per-loop-insts=40 \
|
||||
-mllvm -polly-invariant-load-hoisting")
|
||||
string(CONCAT CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -O3 -ffast-math -fvisibility=hidden -fvisibility-inlines-hidden -funroll-loops -flto "
|
||||
"-ffile-prefix-map=${ANDROID_TOOLCHAIN_ROOT}= "
|
||||
"-ffile-prefix-map=${CMAKE_SOURCE_DIR}= "
|
||||
"-ffile-prefix-map=${CMAKE_BINARY_DIR}= "
|
||||
"-mllvm -polly "
|
||||
"-mllvm -polly-run-dce "
|
||||
"-mllvm -polly-run-inliner "
|
||||
"-mllvm -polly-isl-arg=--no-schedule-serialize-sccs "
|
||||
"-mllvm -polly-ast-use-context "
|
||||
"-mllvm -polly-detect-keep-going "
|
||||
"-mllvm -polly-position=before-vectorizer "
|
||||
"-mllvm -polly-vectorizer=stripmine "
|
||||
"-mllvm -polly-detect-profitability-min-per-loop-insts=40 "
|
||||
"-mllvm -polly-invariant-load-hoisting")
|
||||
endif (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||
|
||||
option(BUILD_TESTING OFF)
|
||||
|
||||
set(XZ_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch)
|
||||
option(XZ_DOC OFF)
|
||||
option(XZ_LZIP_DECODER OFF)
|
||||
option(XZ_MICROLZMA_DECODER OFF)
|
||||
option(XZ_MICROLZMA_ENCODER OFF)
|
||||
option(XZ_TOOL_LZMADEC OFF)
|
||||
option(XZ_TOOL_LZMAINFO OFF)
|
||||
option(XZ_TOOL_XZ OFF)
|
||||
option(XZ_TOOL_XZDEC OFF)
|
||||
FetchContent_Declare(
|
||||
liblzma
|
||||
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
|
||||
GIT_TAG v5.6.3
|
||||
GIT_TAG v5.8.3
|
||||
GIT_SHALLOW 1
|
||||
PATCH_COMMAND git apply --check -R ${XZ_PATCH} || git apply ${XZ_PATCH}
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(liblzma)
|
||||
include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api)
|
||||
|
||||
# Build GNUTLS libnettle
|
||||
if (CMAKE_HOST_SYSTEM_NAME STREQUAL Windows)
|
||||
execute_process(
|
||||
COMMAND wsl wslpath ${CMAKE_CURRENT_LIST_DIR}
|
||||
OUTPUT_VARIABLE CURRENT_DIR
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
)
|
||||
if (CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows")
|
||||
set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}")
|
||||
set(TOOLCHAIN "$ANDROID_HOME/ndk/${NDK_VERSION}/toolchains/llvm/prebuilt/linux-x86_64/bin")
|
||||
set(WSL wsl)
|
||||
else ()
|
||||
set(CURRENT_DIR ${CMAKE_CURRENT_LIST_DIR})
|
||||
set(TOOLCHAIN "${ANDROID_TOOLCHAIN_ROOT}/bin")
|
||||
endif ()
|
||||
set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}")
|
||||
set(CONFIGURE_COMMAND ${WSL} ./.bootstrap && ${WSL} ./configure --disable-public-key --disable-dependency-tracking
|
||||
"--host=${CMAKE_C_COMPILER_TARGET}"
|
||||
"CFLAGS=${CMAKE_C_FLAGS} -Qunused-arguments"
|
||||
"AR=${TOOLCHAIN}/llvm-ar"
|
||||
"CC=${TOOLCHAIN}/clang --target=${CMAKE_C_COMPILER_TARGET}"
|
||||
"RANLIB=${TOOLCHAIN}/llvm-ranlib"
|
||||
)
|
||||
ExternalProject_Add(
|
||||
nettle
|
||||
PREFIX nettle
|
||||
GIT_REPOSITORY https://gitlab.com/gnutls/nettle.git
|
||||
GIT_TAG nettle_3.10_release_20240616
|
||||
GIT_TAG nettle_4.0_release_20260205
|
||||
GIT_SHALLOW 1
|
||||
GIT_CONFIG core.autocrlf=input
|
||||
CONFIGURE_COMMAND bash ${CURRENT_DIR}/configure_nettle.sh ${NDK_VERSION} ${CMAKE_C_COMPILER_TARGET}
|
||||
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS} -Qunused-arguments'"
|
||||
CONFIGURE_COMMAND ${CONFIGURE_COMMAND}
|
||||
BUILD_COMMAND ${WSL} make -j4 libnettle.a
|
||||
INSTALL_COMMAND ""
|
||||
BUILD_IN_SOURCE 1
|
||||
BUILD_BYPRODUCTS nettle/src/nettle/libnettle.a
|
||||
BUILD_BYPRODUCTS <SOURCE_DIR>/libnettle.a
|
||||
)
|
||||
|
||||
ExternalProject_Get_Property(nettle SOURCE_DIR)
|
||||
@@ -85,35 +96,69 @@ option(ENABLE_CAT OFF)
|
||||
option(ENABLE_UNZIP OFF)
|
||||
option(ENABLE_TEST OFF)
|
||||
|
||||
# Configure libarchive link's static lib
|
||||
SET(LIBARCHIVE_CUSTOM_LIBS "libnettle" "liblzma")
|
||||
|
||||
set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
|
||||
set(LIBARCHIVE_PATCH
|
||||
${CMAKE_CURRENT_LIST_DIR}/0001-Fix-zip_time-performance.patch
|
||||
${CMAKE_CURRENT_LIST_DIR}/0002-Use-UTF-8-as-default-charset-on-bionic.patch
|
||||
)
|
||||
FetchContent_Declare(
|
||||
libarchive
|
||||
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
|
||||
GIT_TAG v3.7.7
|
||||
GIT_TAG v3.8.7
|
||||
GIT_SHALLOW 1
|
||||
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(libarchive)
|
||||
target_link_libraries(archive_static liblzma libnettle)
|
||||
include_directories(${libarchive_SOURCE_DIR}/libarchive)
|
||||
|
||||
FetchContent_Declare(
|
||||
libwebp
|
||||
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
|
||||
GIT_TAG v1.6.0
|
||||
GIT_SHALLOW 1
|
||||
)
|
||||
|
||||
option(WEBP_BUILD_ANIM_UTILS OFF)
|
||||
option(WEBP_BUILD_CWEBP OFF)
|
||||
option(WEBP_BUILD_DWEBP OFF)
|
||||
option(WEBP_BUILD_VWEBP OFF)
|
||||
option(WEBP_BUILD_WEBPINFO OFF)
|
||||
option(WEBP_BUILD_LIBWEBPMUX OFF)
|
||||
option(WEBP_BUILD_EXTRAS OFF)
|
||||
option(WEBP_ENABLE_SWAP_16BIT_CSP ON)
|
||||
|
||||
# Disable runtime detection of NEON support as NEON is required since API level 23
|
||||
# https://source.android.com/docs/compatibility/6.0/android-6.0-cdd#3_3_1_application_binary_interfaces
|
||||
unset(ANDROID)
|
||||
FetchContent_MakeAvailable(libwebp)
|
||||
if (CMAKE_SYSTEM_NAME STREQUAL "Android")
|
||||
set(ANDROID 1)
|
||||
endif ()
|
||||
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ../../../../rust-toolchain.toml)
|
||||
unset(Rust_TOOLCHAIN CACHE)
|
||||
FetchContent_Declare(
|
||||
Corrosion
|
||||
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
|
||||
GIT_TAG v0.5
|
||||
GIT_REPOSITORY https://github.com/FooIbar/corrosion.git
|
||||
GIT_TAG v0.6.1
|
||||
GIT_SHALLOW 1
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(Corrosion)
|
||||
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml LOCKED)
|
||||
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
|
||||
if (ANDROID_NATIVE_API_LEVEL GREATER_EQUAL 26)
|
||||
corrosion_set_features(ehviewer_rust FEATURES api-level-26)
|
||||
endif ()
|
||||
corrosion_import_crate(
|
||||
MANIFEST_PATH ../rust/Cargo.toml
|
||||
LOCKED
|
||||
FEATURES $<IF:$<VERSION_GREATER_EQUAL:${ANDROID_NATIVE_API_LEVEL},26>,android-26,android>
|
||||
FLAGS $<$<NOT:$<CONFIG:Debug>>:-Z build-std -Z build-std-features=>
|
||||
)
|
||||
corrosion_add_target_rustflags(ehviewer_rust $<$<NOT:$<CONFIG:Debug>>:-Z unstable-options -C panic=immediate-abort -Z location-detail=none -Z fmt-debug=none>)
|
||||
corrosion_link_libraries(ehviewer_rust android jnigraphics log webp webpdemux)
|
||||
|
||||
# Build and link our app's native lib
|
||||
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c hash.c natsort/strnatcmp.c)
|
||||
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
|
||||
target_link_libraries(
|
||||
${PROJECT_NAME} archive_static ehviewer_rust
|
||||
-Wl,-u,JNI_OnLoad -Wl,--undefined-glob,Java_*
|
||||
-Wl,--version-script,${CMAKE_SOURCE_DIR}/libehviewer.map.txt
|
||||
)
|
||||
+135
-174
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2022 Tarsin Norbin
|
||||
* Copyright 2022-2024 Tarsin Norbin
|
||||
*
|
||||
* This file is part of EhViewer
|
||||
*
|
||||
@@ -45,33 +45,29 @@ typedef struct {
|
||||
typedef struct {
|
||||
const char *filename;
|
||||
int index;
|
||||
size_t size;
|
||||
ssize_t size;
|
||||
void *addr;
|
||||
} entry;
|
||||
|
||||
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static archive_ctx **ctx_pool = NULL;
|
||||
#define CTX_POOL_SIZE 20
|
||||
#define MAX_PARALLEL_DECOMP 4
|
||||
#define max(a, b) ((a) > (b) ? (a) : (b))
|
||||
|
||||
static void *mempool = MAP_FAILED;
|
||||
static size_t *mempoolofs = NULL;
|
||||
static int page_size = 0;
|
||||
|
||||
#define PAGE_ALIGN(x) ((x + page_size - 1) & ~(page_size - 1))
|
||||
|
||||
#define MEMPOOL_ADDR_BY_SORTED_IDX(x) (mempool + (index ? mempoolofs[index - 1] : 0))
|
||||
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
|
||||
|
||||
#define PROT_RW (PROT_WRITE | PROT_READ)
|
||||
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE)
|
||||
|
||||
static pthread_mutex_t ctx_pool_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static archive_ctx **ctx_pool = NULL;
|
||||
static pthread_mutex_t buffer_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static void *decode_buffer[MAX_PARALLEL_DECOMP];
|
||||
static bool need_encrypt = false;
|
||||
static char *passwd = NULL;
|
||||
static void *archiveAddr = MAP_FAILED;
|
||||
static size_t archiveSize = 0;
|
||||
static entry *entries = NULL;
|
||||
static size_t entryCount = 0;
|
||||
static ssize_t max_file_size = 0;
|
||||
|
||||
const char supportExt[10][6] = {
|
||||
#define SUPPORT_EXT_COUNT 11
|
||||
|
||||
const char supportExt[SUPPORT_EXT_COUNT][5] = {
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png",
|
||||
@@ -80,6 +76,7 @@ const char supportExt[10][6] = {
|
||||
"bmp",
|
||||
"ico",
|
||||
"wbmp",
|
||||
"heic",
|
||||
"heif",
|
||||
"avif"
|
||||
};
|
||||
@@ -91,24 +88,19 @@ static inline int filename_is_playable_file(const char *name) {
|
||||
if (!dotptr++)
|
||||
return false;
|
||||
int i;
|
||||
for (i = 0; i < 10; i++)
|
||||
for (i = 0; i < SUPPORT_EXT_COUNT; i++)
|
||||
if (strcmp(dotptr, supportExt[i]) == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static inline const char *archive_entry_pathname2(struct archive_entry *entry) {
|
||||
const char *name = archive_entry_pathname_utf8(entry);
|
||||
return name ? name : archive_entry_pathname(entry);
|
||||
}
|
||||
|
||||
static inline bool archive_entry_is_file(struct archive_entry *entry) {
|
||||
return archive_entry_filetype(entry) == AE_IFREG;
|
||||
}
|
||||
|
||||
static inline bool archive_entry_is_playable(struct archive_entry *entry) {
|
||||
return archive_entry_is_file(entry) &&
|
||||
filename_is_playable_file(archive_entry_pathname2(entry));
|
||||
filename_is_playable_file(archive_entry_pathname(entry));
|
||||
}
|
||||
|
||||
static inline int compare_entries(const void *a, const void *b) {
|
||||
@@ -117,52 +109,64 @@ static inline int compare_entries(const void *a, const void *b) {
|
||||
return strnatcmp(fa, fb);
|
||||
}
|
||||
|
||||
static int archive_map_entries_index(archive_ctx *ctx, bool sort) {
|
||||
#define ADDR_IN_FILE_MAPPING(addr) (addr >= archiveAddr && addr < archiveAddr + archiveSize)
|
||||
|
||||
static bool fill_entry_zero_copy(struct archive *arc, entry *entry) {
|
||||
void *buffer = NULL;
|
||||
size_t buffer_size = 0;
|
||||
la_int64_t output_ofs = 0;
|
||||
archive_read_data_block(arc, (const void **) &buffer, &buffer_size, &output_ofs);
|
||||
bool zero_copy = ADDR_IN_FILE_MAPPING(buffer) && !output_ofs && buffer_size == entry->size;
|
||||
entry->addr = zero_copy ? buffer : NULL;
|
||||
return zero_copy;
|
||||
}
|
||||
|
||||
static void archive_map_entries_index(archive_ctx *ctx, bool sort) {
|
||||
int count = 0;
|
||||
bool zero_copy = true;
|
||||
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
|
||||
const char *name = archive_entry_pathname2(ctx->entry);
|
||||
const char *name = archive_entry_pathname(ctx->entry);
|
||||
if (archive_entry_is_file(ctx->entry) && filename_is_playable_file(name)) {
|
||||
entries[count].filename = strdup(name);
|
||||
entries[count].index = count;
|
||||
entries[count].size = archive_entry_size(ctx->entry);
|
||||
ssize_t size = archive_entry_size(ctx->entry);
|
||||
max_file_size = max(size, max_file_size);
|
||||
entries[count].size = size;
|
||||
// We don't expect zero copy if first content can't do zero copy
|
||||
if (zero_copy) zero_copy = fill_entry_zero_copy(ctx->arc, &entries[count]);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (sort)
|
||||
qsort(entries, entryCount, sizeof(entry), compare_entries);
|
||||
return count;
|
||||
if (sort) qsort(entries, entryCount, sizeof(entry), compare_entries);
|
||||
}
|
||||
|
||||
static bool archive_prealloc_mempool() {
|
||||
mempoolofs = calloc(entryCount, sizeof(size_t));
|
||||
for (int i = 0; i < entryCount; ++i) {
|
||||
if (!i)
|
||||
mempoolofs[i] = PAGE_ALIGN(entries[i].size);
|
||||
else
|
||||
mempoolofs[i] = PAGE_ALIGN(entries[i].size) + mempoolofs[i - 1];
|
||||
static void *acquire_decode_buffer() {
|
||||
void *addr = NULL;
|
||||
pthread_mutex_lock(&buffer_mutex);
|
||||
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
|
||||
addr = decode_buffer[i];
|
||||
if (addr) {
|
||||
decode_buffer[i] = NULL;
|
||||
break;
|
||||
}
|
||||
}
|
||||
mempool = mmap(0, MEMPOOL_SIZE, PROT_RW, MAP_ANON_POOL, -1, 0);
|
||||
if (mempool == MAP_FAILED) {
|
||||
LOGE("%s%s", "mmap failed with error ", strerror(errno));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
pthread_mutex_unlock(&buffer_mutex);
|
||||
if (!addr) addr = malloc(max_file_size);
|
||||
return addr;
|
||||
}
|
||||
|
||||
#define ADDR_IN_FILE_MAPPING(addr) (addr >= archiveAddr && addr < archiveAddr + archiveSize)
|
||||
|
||||
static void mempool_release_pages(void *addr, size_t size) {
|
||||
size = PAGE_ALIGN(size);
|
||||
madvise_log_if_error(addr, size, MADV_DONTNEED);
|
||||
}
|
||||
|
||||
static bool kernel_can_prefault = true;
|
||||
|
||||
static void mempool_prefault_pages(void *addr, size_t size) {
|
||||
size = PAGE_ALIGN(size);
|
||||
if (kernel_can_prefault) {
|
||||
if (madvise(addr, size, MADV_POPULATE_WRITE)) kernel_can_prefault = false;
|
||||
static void release_decode_buffer(void *buffer) {
|
||||
pthread_mutex_lock(&buffer_mutex);
|
||||
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
|
||||
void *addr = decode_buffer[i];
|
||||
if (!addr) {
|
||||
decode_buffer[i] = buffer;
|
||||
pthread_mutex_unlock(&buffer_mutex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&buffer_mutex);
|
||||
free(buffer);
|
||||
}
|
||||
|
||||
static int archive_list_all_entries(archive_ctx *ctx) {
|
||||
@@ -181,18 +185,10 @@ static void archive_release_ctx(archive_ctx *ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
static int archive_alloc_ctx(archive_ctx **ctxptr) {
|
||||
archive_ctx *ctx;
|
||||
int err;
|
||||
ctx = calloc(1, sizeof(archive_ctx));
|
||||
if (!ctx)
|
||||
return -ENOMEM;
|
||||
static archive_ctx *archive_alloc_ctx() {
|
||||
archive_ctx *ctx = calloc(1, sizeof(archive_ctx));
|
||||
ctx->arc = archive_read_new();
|
||||
ctx->using = 1;
|
||||
if (!ctx->arc) {
|
||||
free(ctx);
|
||||
return -ENOMEM;
|
||||
}
|
||||
archive_read_support_format_tar(ctx->arc);
|
||||
archive_read_support_format_7zip(ctx->arc);
|
||||
archive_read_support_format_rar5(ctx->arc);
|
||||
@@ -202,14 +198,14 @@ static int archive_alloc_ctx(archive_ctx **ctxptr) {
|
||||
archive_read_set_option(ctx->arc, "zip", "ignorecrc32", "1");
|
||||
if (passwd)
|
||||
archive_read_add_passphrase(ctx->arc, passwd);
|
||||
err = archive_read_open_memory(ctx->arc, archiveAddr, archiveSize);
|
||||
if (err) {
|
||||
int err = archive_read_open_memory(ctx->arc, archiveAddr, archiveSize);
|
||||
if (err < ARCHIVE_OK) {
|
||||
LOGE("%s%s", "Open archive failed: ", archive_error_string(ctx->arc));
|
||||
archive_read_free(ctx->arc);
|
||||
free(ctx);
|
||||
return err;
|
||||
return NULL;
|
||||
}
|
||||
*ctxptr = ctx;
|
||||
return 0;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
static int archive_skip_to_index(archive_ctx *ctx, int index) {
|
||||
@@ -226,7 +222,7 @@ static int archive_skip_to_index(archive_ctx *ctx, int index) {
|
||||
static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
|
||||
int ret;
|
||||
archive_ctx *ctx = NULL;
|
||||
pthread_mutex_lock(&mutex);
|
||||
pthread_mutex_lock(&ctx_pool_mutex);
|
||||
for (int i = 0; i < CTX_POOL_SIZE; i++) {
|
||||
if (!ctx_pool[i])
|
||||
continue;
|
||||
@@ -241,16 +237,14 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
|
||||
}
|
||||
if (ctx)
|
||||
ctx->using = 1;
|
||||
pthread_mutex_unlock(&mutex);
|
||||
pthread_mutex_unlock(&ctx_pool_mutex);
|
||||
|
||||
if (!ctx) {
|
||||
archive_ctx *victimCtx = NULL;
|
||||
int victimIdx = 0;
|
||||
int replace = 1;
|
||||
ret = archive_alloc_ctx(&ctx);
|
||||
if (ret)
|
||||
return ret;
|
||||
pthread_mutex_lock(&mutex);
|
||||
ctx = archive_alloc_ctx();
|
||||
pthread_mutex_lock(&ctx_pool_mutex);
|
||||
for (int i = 0; i < CTX_POOL_SIZE; i++) {
|
||||
if (!ctx_pool[i]) {
|
||||
ctx_pool[i] = ctx;
|
||||
@@ -265,13 +259,13 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
|
||||
}
|
||||
}
|
||||
if (replace) ctx_pool[victimIdx] = ctx;
|
||||
pthread_mutex_unlock(&mutex);
|
||||
pthread_mutex_unlock(&ctx_pool_mutex);
|
||||
if (replace) archive_release_ctx(victimCtx);
|
||||
}
|
||||
ret = archive_skip_to_index(ctx, idx);
|
||||
if (ret != idx) {
|
||||
ret = archive_errno(ctx->arc);
|
||||
LOGE("Skip to index failed:%s", archive_error_string(ctx->arc));
|
||||
LOGE("Skip to index failed: %s", archive_error_string(ctx->arc));
|
||||
archive_release_ctx(ctx);
|
||||
return ret;
|
||||
}
|
||||
@@ -283,7 +277,6 @@ JNIEXPORT jint JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
page_size = getpagesize();
|
||||
archive_ctx *ctx = NULL;
|
||||
archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0);
|
||||
if (archiveAddr == MAP_FAILED) {
|
||||
@@ -292,106 +285,74 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
|
||||
}
|
||||
archiveSize = size;
|
||||
ctx_pool = calloc(CTX_POOL_SIZE, sizeof(archive_ctx **));
|
||||
if (!ctx_pool) {
|
||||
LOGE("Allocate archive ctx pool failed:ENOMEM");
|
||||
ctx = archive_alloc_ctx();
|
||||
if (!ctx) return 0;
|
||||
|
||||
entryCount = archive_list_all_entries(ctx);
|
||||
LOGI("%s%zu%s", "Found ", entryCount, " images in archive");
|
||||
if (!entryCount) {
|
||||
LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc));
|
||||
archive_release_ctx(ctx);
|
||||
return 0;
|
||||
}
|
||||
int r = archive_alloc_ctx(&ctx);
|
||||
if (r) {
|
||||
if (r == -ENOMEM)
|
||||
LOGE("%s", "Mem alloc failed");
|
||||
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
|
||||
return 0;
|
||||
} else {
|
||||
entryCount = archive_list_all_entries(ctx);
|
||||
LOGI("%s%zu%s", "Found ", entryCount, " image entries in archive");
|
||||
if (!entryCount) {
|
||||
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
|
||||
archive_release_ctx(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// We must read through the file|vm then we can know whether it is encrypted
|
||||
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
|
||||
switch (encryptRet) {
|
||||
case 1: // At lease 1 encrypted entry
|
||||
need_encrypt = true;
|
||||
break;
|
||||
case 0: // format supports but no encrypted entry found
|
||||
default:
|
||||
need_encrypt = false;
|
||||
}
|
||||
|
||||
int format = archive_format(ctx->arc);
|
||||
switch (format) {
|
||||
case ARCHIVE_FORMAT_ZIP:
|
||||
case ARCHIVE_FORMAT_RAR_V5:
|
||||
madvise_log_if_error(archiveAddr, archiveSize, MADV_SEQUENTIAL);
|
||||
break;
|
||||
case ARCHIVE_FORMAT_7ZIP: // Seek is bad
|
||||
madvise_log_if_error(archiveAddr, archiveSize, MADV_RANDOM);
|
||||
break;
|
||||
default:;
|
||||
}
|
||||
// We must read through the file|vm then we can know whether it is encrypted
|
||||
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
|
||||
switch (encryptRet) {
|
||||
case 1: // At lease 1 encrypted entry
|
||||
need_encrypt = true;
|
||||
break;
|
||||
case 0: // format supports but no encrypted entry found
|
||||
default:
|
||||
need_encrypt = false;
|
||||
}
|
||||
|
||||
archive_release_ctx(ctx);
|
||||
|
||||
r = archive_alloc_ctx(&ctx);
|
||||
if (r) {
|
||||
if (r == -ENOMEM)
|
||||
LOGE("%s", "Mem alloc failed");
|
||||
r = 0;
|
||||
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
|
||||
} else {
|
||||
entries = calloc(entryCount, sizeof(entry));
|
||||
r = archive_map_entries_index(ctx, sort_entries);
|
||||
if (!archive_prealloc_mempool()) {
|
||||
r = 0;
|
||||
}
|
||||
int format = archive_format(ctx->arc);
|
||||
switch (format) {
|
||||
case ARCHIVE_FORMAT_ZIP:
|
||||
case ARCHIVE_FORMAT_RAR_V5:
|
||||
madvise_log_if_error(archiveAddr, archiveSize, MADV_SEQUENTIAL);
|
||||
break;
|
||||
case ARCHIVE_FORMAT_7ZIP: // Seek is bad
|
||||
madvise_log_if_error(archiveAddr, archiveSize, MADV_RANDOM);
|
||||
break;
|
||||
default:;
|
||||
}
|
||||
|
||||
archive_release_ctx(ctx);
|
||||
return r;
|
||||
|
||||
ctx = archive_alloc_ctx();
|
||||
if (!ctx) return 0;
|
||||
entries = calloc(entryCount, sizeof(entry));
|
||||
archive_map_entries_index(ctx, sort_entries);
|
||||
archive_release_ctx(ctx);
|
||||
return (int) entryCount;
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz, jint index) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
void *addr = MEMPOOL_ADDR_BY_SORTED_IDX(index);
|
||||
size_t size = entries[index].size;
|
||||
index = entries[index].index;
|
||||
archive_ctx *ctx = NULL;
|
||||
int ret = archive_get_ctx(&ctx, index);
|
||||
if (ret) return 0;
|
||||
void *buffer = NULL;
|
||||
size_t buffer_size = 0;
|
||||
la_int64_t output_ofs = 0;
|
||||
archive_read_data_block(ctx->arc, (const void **) &buffer, &buffer_size, &output_ofs);
|
||||
bool zero_copy = ADDR_IN_FILE_MAPPING(buffer) && !output_ofs && buffer_size == size;
|
||||
if (zero_copy) {
|
||||
ctx->using = 0;
|
||||
return (*env)->NewDirectByteBuffer(env, buffer, buffer_size);
|
||||
entry *entry = &entries[index];
|
||||
ssize_t size = entry->size;
|
||||
if (entry->addr) {
|
||||
return (*env)->NewDirectByteBuffer(env, entry->addr, size);
|
||||
} else {
|
||||
size_t bytes = 0;
|
||||
mempool_prefault_pages(addr, size);
|
||||
do {
|
||||
memcpy(addr + output_ofs, buffer, buffer_size);
|
||||
bytes += buffer_size;
|
||||
ret = archive_read_data_block(ctx->arc, (const void **) &buffer, &buffer_size, &output_ofs);
|
||||
} while (bytes != size && !ret);
|
||||
ctx->using = 0;
|
||||
if (bytes == size) {
|
||||
return (*env)->NewDirectByteBuffer(env, addr, size);
|
||||
} else {
|
||||
if (ret < 0) {
|
||||
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
|
||||
archive_ctx *ctx = NULL;
|
||||
if (!archive_get_ctx(&ctx, entry->index)) {
|
||||
void *addr = acquire_decode_buffer();
|
||||
ssize_t bytes = archive_read_data(ctx->arc, addr, size);
|
||||
ctx->using = 0;
|
||||
if (bytes == size) {
|
||||
return (*env)->NewDirectByteBuffer(env, addr, size);
|
||||
} else {
|
||||
LOGE("%s", "No enough data read, WTF?");
|
||||
if (bytes < 0) {
|
||||
LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc));
|
||||
} else {
|
||||
LOGE("%s", "No enough data read, WTF?");
|
||||
}
|
||||
}
|
||||
release_decode_buffer(addr);
|
||||
}
|
||||
mempool_release_pages(addr, size);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -413,12 +374,11 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
|
||||
munmap(archiveAddr, archiveSize);
|
||||
archiveAddr = MAP_FAILED;
|
||||
}
|
||||
if (mempool != MAP_FAILED) {
|
||||
munmap(mempool, MEMPOOL_SIZE);
|
||||
mempool = MAP_FAILED;
|
||||
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
|
||||
free(decode_buffer[i]);
|
||||
decode_buffer[i] = NULL;
|
||||
}
|
||||
free(mempoolofs);
|
||||
mempoolofs = NULL;
|
||||
max_file_size = 0;
|
||||
if (entries) {
|
||||
for (int i = 0; i < entryCount; ++i) {
|
||||
free((void *) entries[i].filename);
|
||||
@@ -445,15 +405,15 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
|
||||
passwd = realloc(passwd, len + 1);
|
||||
(*env)->GetStringUTFRegion(env, str, 0, len, passwd);
|
||||
passwd[len] = 0;
|
||||
archive_alloc_ctx(&ctx);
|
||||
void *tmpBuf = alloca(4096);
|
||||
ctx = archive_alloc_ctx();
|
||||
char tmpBuf[4096];
|
||||
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
|
||||
if (!archive_entry_is_playable(entry))
|
||||
continue;
|
||||
if (!archive_entry_is_encrypted(entry))
|
||||
continue;
|
||||
if (archive_read_data(ctx->arc, tmpBuf, 4096) < ARCHIVE_OK) {
|
||||
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
|
||||
LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc));
|
||||
ret = false;
|
||||
}
|
||||
break;
|
||||
@@ -489,8 +449,9 @@ JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz, jobject buffer) {
|
||||
EH_UNUSED(thiz);
|
||||
void *addr = (*env)->GetDirectBufferAddress(env, buffer);
|
||||
size_t size = (*env)->GetDirectBufferCapacity(env, buffer);
|
||||
if (!ADDR_IN_FILE_MAPPING(addr)) mempool_release_pages(addr, size);
|
||||
if (!ADDR_IN_FILE_MAPPING(addr)) {
|
||||
release_decode_buffer(addr);
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
@@ -515,7 +476,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz,
|
||||
archive_entry_copy_stat(entry, &st);
|
||||
archive_entry_set_perm(entry, 0644);
|
||||
archive_write_header(arc, entry);
|
||||
int len;
|
||||
size_t len;
|
||||
do {
|
||||
len = read(fd, buff, sizeof(buff));
|
||||
archive_write_data(arc, buff, len);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
SUFFIX=toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
|
||||
CI_NDK_LOCATION=$ANDROID_HOME/ndk/$1/$SUFFIX
|
||||
ARCH_NDK_LOCATION=$ANDROID_HOME/ndk-bundle/$SUFFIX
|
||||
if test -e $CI_NDK_LOCATION ;then
|
||||
TOOLCHAIN=$CI_NDK_LOCATION
|
||||
else
|
||||
TOOLCHAIN=$ARCH_NDK_LOCATION
|
||||
fi
|
||||
TARGET=$2
|
||||
./.bootstrap && ./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
|
||||
AR=$TOOLCHAIN/llvm-ar \
|
||||
CC="$TOOLCHAIN/clang -target $TARGET" \
|
||||
RANLIB=$TOOLCHAIN/llvm-ranlib
|
||||
@@ -37,7 +37,7 @@ static void doRewrite(byte *addr, size_t size) {
|
||||
byte *end = addr + i + 4;
|
||||
if (end[4] != 0) continue;
|
||||
int frameDelay = end[2] << 8 | end[1];
|
||||
if (frameDelay > MINIMUM_FRAME_DELAY)
|
||||
if (frameDelay >= MINIMUM_FRAME_DELAY)
|
||||
break; // Quit if the first block looks normal, for performance
|
||||
end[1] = DEFAULT_FRAME_DELAY;
|
||||
end[2] = 0;
|
||||
|
||||
@@ -24,7 +24,7 @@ Java_com_hippo_ehviewer_jni_HashKt_sha1(JNIEnv *env, jclass clazz, jint fd) {
|
||||
}
|
||||
|
||||
byte digest[SHA1_DIGEST_SIZE];
|
||||
sha1_digest(&ctx, SHA1_DIGEST_SIZE, digest);
|
||||
sha1_digest(&ctx, digest);
|
||||
|
||||
byte byte;
|
||||
char hex_digest[2 * SHA1_DIGEST_SIZE + 1];
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
global:
|
||||
JNI_OnLoad;
|
||||
Java_*;
|
||||
local:
|
||||
*;
|
||||
};
|
||||
-476
@@ -1,476 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.foundation.gestures
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.DecayAnimationSpec
|
||||
import androidx.compose.animation.core.FloatDecayAnimationSpec
|
||||
import androidx.compose.animation.core.generateDecayAnimationSpec
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
|
||||
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
|
||||
import androidx.compose.foundation.gestures.snapping.snapFlingBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.node.LayoutModifierNode
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
import androidx.compose.ui.node.ObserverModifierNode
|
||||
import androidx.compose.ui.node.currentValueOf
|
||||
import androidx.compose.ui.node.observeReads
|
||||
import androidx.compose.ui.node.requireLayoutDirection
|
||||
import androidx.compose.ui.platform.InspectorInfo
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
fun <T> Modifier.anchoredHorizontalDraggable(
|
||||
state: AnchoredDraggableState<T>,
|
||||
enableDragFromStartToEnd: Boolean = true,
|
||||
enableDragFromEndToStart: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
startDragImmediately: Boolean = state.isAnimationRunning,
|
||||
flingBehavior: FlingBehavior? = null,
|
||||
): Modifier = this then AnchoredHorizontalDraggableElement(
|
||||
state = state,
|
||||
canDrag = { (x, y) ->
|
||||
val enableDrag = if (x > 0) enableDragFromStartToEnd else enableDragFromEndToStart
|
||||
enableDrag && x.absoluteValue > y.absoluteValue * 2f
|
||||
},
|
||||
enabled = enabled,
|
||||
startDragImmediately = startDragImmediately,
|
||||
flingBehavior = flingBehavior,
|
||||
)
|
||||
|
||||
private class AnchoredHorizontalDraggableElement<T>(
|
||||
private val state: AnchoredDraggableState<T>,
|
||||
private val canDrag: (Offset) -> Boolean,
|
||||
private val enabled: Boolean,
|
||||
private val startDragImmediately: Boolean,
|
||||
private val flingBehavior: FlingBehavior?,
|
||||
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
|
||||
override fun create() = AnchoredHorizontalDraggableNode(
|
||||
state,
|
||||
canDrag,
|
||||
enabled,
|
||||
startDragImmediately,
|
||||
flingBehavior,
|
||||
)
|
||||
|
||||
override fun update(node: AnchoredHorizontalDraggableNode<T>) {
|
||||
node.update(
|
||||
state,
|
||||
canDrag,
|
||||
enabled,
|
||||
startDragImmediately,
|
||||
flingBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = state.hashCode()
|
||||
result = 31 * result + canDrag.hashCode()
|
||||
result = 31 * result + enabled.hashCode()
|
||||
result = 31 * result + startDragImmediately.hashCode()
|
||||
result = 31 * result + flingBehavior.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
if (other !is AnchoredHorizontalDraggableElement<*>) return false
|
||||
|
||||
if (state != other.state) return false
|
||||
if (canDrag != other.canDrag) return false
|
||||
if (enabled != other.enabled) return false
|
||||
if (startDragImmediately != other.startDragImmediately) return false
|
||||
if (flingBehavior != other.flingBehavior) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
name = "anchoredDraggable"
|
||||
properties["state"] = state
|
||||
properties["canDrag"] = canDrag
|
||||
properties["enabled"] = enabled
|
||||
properties["startDragImmediately"] = startDragImmediately
|
||||
properties["flingBehavior"] = flingBehavior
|
||||
}
|
||||
}
|
||||
|
||||
private class AnchoredHorizontalDraggableNode<T>(
|
||||
private var state: AnchoredDraggableState<T>,
|
||||
private var canDrag: (Offset) -> Boolean,
|
||||
enabled: Boolean,
|
||||
private var startDragImmediately: Boolean,
|
||||
private var flingBehavior: FlingBehavior?,
|
||||
) : HorizontalDragGestureNode(enabled = enabled),
|
||||
ObserverModifierNode {
|
||||
|
||||
lateinit var resolvedFlingBehavior: FlingBehavior
|
||||
lateinit var density: Density
|
||||
|
||||
private val isReverseDirection: Boolean
|
||||
get() = requireLayoutDirection() == LayoutDirection.Rtl
|
||||
|
||||
override fun onAttach() {
|
||||
updateFlingBehavior(flingBehavior)
|
||||
}
|
||||
|
||||
override fun onObservedReadsChanged() {
|
||||
val newDensity = currentValueOf(LocalDensity)
|
||||
if (density != newDensity) {
|
||||
density = newDensity
|
||||
updateFlingBehavior(flingBehavior)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFlingBehavior(newFlingBehavior: FlingBehavior?) {
|
||||
// Fall back to default fling behavior if the new fling behavior is null
|
||||
this.resolvedFlingBehavior =
|
||||
if (newFlingBehavior == null) {
|
||||
// Only register for LocalDensity snapshot updates if we are creating a decay
|
||||
observeReads { density = currentValueOf(LocalDensity) }
|
||||
anchoredDraggableFlingBehavior(
|
||||
snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec,
|
||||
positionalThreshold = AnchoredDraggableDefaults.PositionalThreshold,
|
||||
density = density,
|
||||
state = state,
|
||||
)
|
||||
} else {
|
||||
newFlingBehavior
|
||||
}
|
||||
}
|
||||
|
||||
override fun canDrag(change: PointerInputChange): Boolean = canDrag(change.positionChange().reverseIfNeeded())
|
||||
|
||||
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
|
||||
state.anchoredDrag {
|
||||
forEachDelta { dragDelta ->
|
||||
dragTo(state.newOffsetForDelta(dragDelta.delta.reverseIfNeeded().x))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDragStarted(startedPosition: Offset) { }
|
||||
|
||||
override fun onDragStopped(velocity: Velocity) {
|
||||
if (!isAttached) return
|
||||
coroutineScope.launch {
|
||||
fling(velocity.reverseIfNeeded().x)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fling(velocity: Float): Float {
|
||||
var leftoverVelocity = velocity
|
||||
state.anchoredDrag {
|
||||
val scrollScope =
|
||||
object : ScrollScope {
|
||||
override fun scrollBy(pixels: Float): Float {
|
||||
val newOffset = state.newOffsetForDelta(pixels)
|
||||
val consumed = newOffset - state.offset
|
||||
dragTo(newOffset)
|
||||
return consumed
|
||||
}
|
||||
}
|
||||
with(resolvedFlingBehavior) {
|
||||
leftoverVelocity = scrollScope.performFling(velocity)
|
||||
}
|
||||
}
|
||||
return leftoverVelocity
|
||||
}
|
||||
|
||||
override fun startDragImmediately(): Boolean = startDragImmediately
|
||||
|
||||
fun update(
|
||||
state: AnchoredDraggableState<T>,
|
||||
canDrag: (Offset) -> Boolean,
|
||||
enabled: Boolean,
|
||||
startDragImmediately: Boolean,
|
||||
flingBehavior: FlingBehavior?,
|
||||
) {
|
||||
this.flingBehavior = flingBehavior
|
||||
|
||||
var resetPointerInputHandling = false
|
||||
|
||||
if (this.state != state) {
|
||||
this.state = state
|
||||
updateFlingBehavior(flingBehavior)
|
||||
resetPointerInputHandling = true
|
||||
}
|
||||
|
||||
this.canDrag = canDrag
|
||||
this.startDragImmediately = startDragImmediately
|
||||
|
||||
update(
|
||||
enabled = enabled,
|
||||
shouldResetPointerInputHandling = resetPointerInputHandling,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Velocity.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
|
||||
private fun Offset.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
|
||||
}
|
||||
|
||||
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) = ((if (offset.isNaN()) 0f else offset) + delta)
|
||||
.coerceIn(anchors.minPosition(), anchors.maxPosition())
|
||||
|
||||
/**
|
||||
* Compute the target anchor based on the [currentOffset], [velocity] and [positionalThreshold] and
|
||||
* [velocityThreshold].
|
||||
*
|
||||
* @return The suggested target anchor
|
||||
*/
|
||||
@ExperimentalFoundationApi
|
||||
private fun <T> DraggableAnchors<T>.computeTarget(
|
||||
currentOffset: Float,
|
||||
velocity: Float,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
velocityThreshold: () -> Float,
|
||||
): T {
|
||||
val currentAnchors = this
|
||||
require(!currentOffset.isNaN()) { "The offset provided to computeTarget must not be NaN." }
|
||||
val velocitySign = sign(velocity)
|
||||
val isMovingForward = velocitySign == 1.0f
|
||||
val isMoving = isMovingForward || velocitySign == -1.0f
|
||||
// When we're not moving, just pick the closest anchor and don't consider directionality
|
||||
return if (!isMoving) {
|
||||
currentAnchors.closestAnchor(currentOffset)!!
|
||||
} else if (abs(velocity) >= abs(velocityThreshold())) {
|
||||
currentAnchors.closestAnchor(currentOffset, searchUpwards = isMovingForward)!!
|
||||
} else {
|
||||
val left = currentAnchors.closestAnchor(currentOffset, false)!!
|
||||
val leftAnchorPosition = currentAnchors.positionOf(left)
|
||||
val right = currentAnchors.closestAnchor(currentOffset, true)!!
|
||||
val rightAnchorPosition = currentAnchors.positionOf(right)
|
||||
val distance = abs(leftAnchorPosition - rightAnchorPosition)
|
||||
val relativeThreshold = abs(positionalThreshold(distance))
|
||||
val closestAnchorFromStart =
|
||||
if (isMovingForward) leftAnchorPosition else rightAnchorPosition
|
||||
val relativePosition = abs(closestAnchorFromStart - currentOffset)
|
||||
if (relativePosition <= relativeThreshold) left else right
|
||||
}
|
||||
}
|
||||
|
||||
// https://issuetracker.google.com/374241709
|
||||
@Composable
|
||||
fun <T> flingBehavior(
|
||||
state: AnchoredDraggableState<T>,
|
||||
positionalThreshold: (totalDistance: Float) -> Float = AnchoredDraggableDefaults.PositionalThreshold,
|
||||
animationSpec: AnimationSpec<Float> = AnchoredDraggableDefaults.SnapAnimationSpec,
|
||||
): TargetedFlingBehavior {
|
||||
val density = LocalDensity.current
|
||||
return remember(density, state, positionalThreshold, animationSpec) {
|
||||
anchoredDraggableFlingBehavior(
|
||||
state = state,
|
||||
density = density,
|
||||
positionalThreshold = positionalThreshold,
|
||||
snapAnimationSpec = animationSpec,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a [FlingBehavior] for use with [Modifier.anchoredDraggable].
|
||||
*
|
||||
* @param state The [AnchoredDraggableState] that will be used for the fling animation
|
||||
* @param positionalThreshold A positional threshold that needs to be crossed in order to reach the
|
||||
* next anchor when flinging, in pixels. This can be a derived from the distance that the lambda
|
||||
* is invoked with.
|
||||
* @param snapAnimationSpec The animation spec that will be used to snap to a new state.
|
||||
*/
|
||||
@ExperimentalFoundationApi
|
||||
internal fun <T> anchoredDraggableFlingBehavior(
|
||||
state: AnchoredDraggableState<T>,
|
||||
density: Density,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
snapAnimationSpec: AnimationSpec<Float>,
|
||||
): TargetedFlingBehavior = snapFlingBehavior(
|
||||
decayAnimationSpec = NoOpDecayAnimationSpec,
|
||||
snapAnimationSpec = snapAnimationSpec,
|
||||
snapLayoutInfoProvider =
|
||||
AnchoredDraggableLayoutInfoProvider(
|
||||
state = state,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = { with(density) { 125.dp.toPx() } },
|
||||
),
|
||||
)
|
||||
|
||||
@Suppress("FunctionName")
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun <T> AnchoredDraggableLayoutInfoProvider(
|
||||
state: AnchoredDraggableState<T>,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
velocityThreshold: () -> Float,
|
||||
): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider {
|
||||
|
||||
// We never decay in AnchoredDraggable's fling
|
||||
override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f
|
||||
|
||||
override fun calculateSnapOffset(velocity: Float): Float {
|
||||
val currentOffset = state.requireOffset()
|
||||
val target =
|
||||
state.anchors.computeTarget(
|
||||
currentOffset = currentOffset,
|
||||
velocity = velocity,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = velocityThreshold,
|
||||
)
|
||||
return state.anchors.positionOf(target) - currentOffset
|
||||
}
|
||||
}
|
||||
|
||||
private val NoOpDecayAnimationSpec: DecayAnimationSpec<Float> =
|
||||
object : FloatDecayAnimationSpec {
|
||||
override val absVelocityThreshold = 0f
|
||||
|
||||
override fun getValueFromNanos(
|
||||
playTimeNanos: Long,
|
||||
initialValue: Float,
|
||||
initialVelocity: Float,
|
||||
) = 0f
|
||||
|
||||
override fun getDurationNanos(initialValue: Float, initialVelocity: Float) = 0L
|
||||
|
||||
override fun getVelocityFromNanos(
|
||||
playTimeNanos: Long,
|
||||
initialValue: Float,
|
||||
initialVelocity: Float,
|
||||
) = 0f
|
||||
|
||||
override fun getTargetValue(initialValue: Float, initialVelocity: Float) = 0f
|
||||
}.generateDecayAnimationSpec()
|
||||
|
||||
/**
|
||||
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
|
||||
* node's size and offsetting it.
|
||||
* It considers lookahead and reports the appropriate size and measurement for the appropriate
|
||||
* phase.
|
||||
*
|
||||
* @param state The state the anchors should be attached to
|
||||
* @param orientation The orientation the component should be offset in
|
||||
* @param anchors Lambda to calculate the anchors based on this layout's size and the incoming
|
||||
* constraints. These can be useful to avoid subcomposition.
|
||||
*/
|
||||
internal fun <T> Modifier.draggableAnchors(
|
||||
state: AnchoredDraggableState<T>,
|
||||
orientation: Orientation,
|
||||
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
) = this then DraggableAnchorsElement(state, anchors, orientation)
|
||||
|
||||
private class DraggableAnchorsElement<T>(
|
||||
private val state: AnchoredDraggableState<T>,
|
||||
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
private val orientation: Orientation,
|
||||
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
|
||||
|
||||
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
|
||||
|
||||
override fun update(node: DraggableAnchorsNode<T>) {
|
||||
node.state = state
|
||||
node.anchors = anchors
|
||||
node.orientation = orientation
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
if (other !is DraggableAnchorsElement<*>) return false
|
||||
|
||||
if (state != other.state) return false
|
||||
if (anchors !== other.anchors) return false
|
||||
if (orientation != other.orientation) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = state.hashCode()
|
||||
result = 31 * result + anchors.hashCode()
|
||||
result = 31 * result + orientation.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
debugInspectorInfo {
|
||||
properties["state"] = state
|
||||
properties["anchors"] = anchors
|
||||
properties["orientation"] = orientation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DraggableAnchorsNode<T>(
|
||||
var state: AnchoredDraggableState<T>,
|
||||
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
var orientation: Orientation,
|
||||
) : Modifier.Node(),
|
||||
LayoutModifierNode {
|
||||
private var didLookahead: Boolean = false
|
||||
|
||||
override fun onDetach() {
|
||||
didLookahead = false
|
||||
}
|
||||
|
||||
override fun MeasureScope.measure(
|
||||
measurable: Measurable,
|
||||
constraints: Constraints,
|
||||
): MeasureResult {
|
||||
val placeable = measurable.measure(constraints)
|
||||
// If we are in a lookahead pass, we only want to update the anchors here and not in
|
||||
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
|
||||
// update the anchors in the main pass.
|
||||
if (!isLookingAhead || !didLookahead) {
|
||||
val size = IntSize(placeable.width, placeable.height)
|
||||
val newAnchorResult = anchors(size, constraints)
|
||||
state.updateAnchors(newAnchorResult.first, newAnchorResult.second)
|
||||
}
|
||||
didLookahead = isLookingAhead || didLookahead
|
||||
return layout(placeable.width, placeable.height) {
|
||||
// In a lookahead pass, we use the position of the current target as this is where any
|
||||
// ongoing animations would move. If the component is in a settled state, lookahead
|
||||
// and post-lookahead will converge.
|
||||
val offset = if (isLookingAhead) {
|
||||
state.anchors.positionOf(state.targetValue)
|
||||
} else {
|
||||
state.requireOffset()
|
||||
}
|
||||
val xOffset = if (orientation == Orientation.Horizontal) offset else 0f
|
||||
val yOffset = if (orientation == Orientation.Vertical) offset else 0f
|
||||
placeable.placeRelative(xOffset.roundToInt(), yOffset.roundToInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.interaction.Interaction
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.OutputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
|
||||
import androidx.compose.material3.SearchBarDefaults.inputFieldColors
|
||||
import androidx.compose.material3.SearchBarDefaults.inputFieldShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.ColorProducer
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* A text field to input a query in a search bar.
|
||||
*
|
||||
* @param state [TextFieldState] that holds the internal editing state of the input field.
|
||||
* @param onSearch the callback to be invoked when the input service triggers the
|
||||
* [ImeAction.Search] action. The current query in the [state] comes as a parameter of the
|
||||
* callback.
|
||||
* @param expanded whether the search bar is expanded and showing search results.
|
||||
* @param onExpandedChange the callback to be invoked when the search bar's expanded state is
|
||||
* changed.
|
||||
* @param modifier the [Modifier] to be applied to this input field.
|
||||
* @param enabled the enabled state of this input field. When `false`, this component will not
|
||||
* respond to user input, and it will appear visually disabled and disabled to accessibility
|
||||
* services.
|
||||
* @param readOnly controls the editable state of the input field. When `true`, the field cannot
|
||||
* be modified. However, a user can focus it and copy text from it.
|
||||
* @param textStyle the style to be applied to the input text. Defaults to [LocalTextStyle].
|
||||
* @param placeholder the placeholder to be displayed when the input text is empty.
|
||||
* @param leadingIcon the leading icon to be displayed at the start of the input field.
|
||||
* @param trailingIcon the trailing icon to be displayed at the end of the input field.
|
||||
* @param prefix the optional prefix to be displayed before the input text.
|
||||
* @param suffix the optional suffix to be displayed after the input text.
|
||||
* @param inputTransformation optional [InputTransformation] that will be used to transform
|
||||
* changes to the [TextFieldState] made by the user. The transformation will be applied to
|
||||
* changes made by hardware and software keyboard events, pasting or dropping text,
|
||||
* accessibility services, and tests. The transformation will _not_ be applied when changing
|
||||
* the [state] programmatically, or when the transformation is changed. If the transformation
|
||||
* is changed on an existing text field, it will be applied to the next user edit. The
|
||||
* transformation will not immediately affect the current [state].
|
||||
* @param outputTransformation optional [OutputTransformation] that transforms how the contents
|
||||
* of the text field are presented.
|
||||
* @param scrollState scroll state that manages the horizontal scroll of the input field.
|
||||
* @param shape the shape of the input field.
|
||||
* @param colors [TextFieldColors] that will be used to resolve the colors used for this input
|
||||
* field in different states. See [SearchBarDefaults.inputFieldColors].
|
||||
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
|
||||
* emitting [Interaction]s for this input field. You can use this to change the search bar's
|
||||
* appearance or preview the search bar in different states. Note that if `null` is provided,
|
||||
* interactions will still happen internally.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Composable
|
||||
fun SearchBarInputField(
|
||||
state: TextFieldState,
|
||||
onSearch: (String) -> Unit,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
prefix: @Composable (() -> Unit)? = null,
|
||||
suffix: @Composable (() -> Unit)? = null,
|
||||
inputTransformation: InputTransformation? = null,
|
||||
outputTransformation: OutputTransformation? = null,
|
||||
scrollState: ScrollState = rememberScrollState(),
|
||||
shape: Shape = inputFieldShape,
|
||||
colors: TextFieldColors = inputFieldColors(),
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
|
||||
|
||||
val focused = interactionSource.collectIsFocusedAsState().value
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val textColor = textStyle.color.takeOrElse {
|
||||
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
|
||||
}
|
||||
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
|
||||
|
||||
BasicTextField(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
.sizeIn(
|
||||
minWidth = SearchBarMinWidth,
|
||||
maxWidth = SearchBarMaxWidth,
|
||||
minHeight = InputFieldHeight,
|
||||
)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { if (it.isFocused) onExpandedChange(true) }
|
||||
.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
textStyle = mergedTextStyle,
|
||||
cursorBrush = SolidColor(colors.cursorColor),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
onKeyboardAction = { onSearch(state.text.toString()) },
|
||||
interactionSource = interactionSource,
|
||||
inputTransformation = inputTransformation,
|
||||
outputTransformation = outputTransformation,
|
||||
scrollState = scrollState,
|
||||
decorator = TextFieldDefaults.decorator(
|
||||
state = state,
|
||||
enabled = enabled,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
outputTransformation = outputTransformation,
|
||||
interactionSource = interactionSource,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon?.let { leading ->
|
||||
{ Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } }
|
||||
},
|
||||
trailingIcon = trailingIcon?.let { trailing ->
|
||||
{ Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } }
|
||||
},
|
||||
prefix = prefix,
|
||||
suffix = suffix,
|
||||
colors = colors,
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
|
||||
container = {
|
||||
val containerColor =
|
||||
animateColorAsState(
|
||||
targetValue = if (focused) colors.focusedContainerColor else colors.unfocusedContainerColor,
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "ContainerColor",
|
||||
)
|
||||
Box(Modifier.textFieldBackground(containerColor::value, shape))
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
val shouldClearFocus = !expanded && focused
|
||||
LaunchedEffect(expanded) {
|
||||
if (shouldClearFocus) {
|
||||
// Not strictly needed according to the motion spec, but since the animation
|
||||
// already has a delay, this works around b/261632544.
|
||||
delay(AnimationDelayMillis.toLong())
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.textFieldBackground(
|
||||
color: ColorProducer,
|
||||
shape: Shape,
|
||||
) = drawWithCache {
|
||||
val outline = shape.createOutline(size, layoutDirection, this)
|
||||
onDrawBehind { drawOutline(outline, color = color()) }
|
||||
}
|
||||
|
||||
private val SearchBarMinWidth = 360.dp
|
||||
private val SearchBarMaxWidth = 720.dp
|
||||
private val SearchBarIconOffsetX = 4.dp
|
||||
|
||||
@Suppress("ktlint:standard:property-naming")
|
||||
private const val AnimationDelayMillis = 100
|
||||
@@ -1,161 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.accompanist.systemuicontroller
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
/**
|
||||
* A class which provides easy-to-use utilities for updating the System UI bar
|
||||
* colors within Jetpack Compose.
|
||||
*/
|
||||
@Stable
|
||||
interface SystemUiController {
|
||||
|
||||
/**
|
||||
* Control for the behavior of the system bars. This value should be one of the
|
||||
* [WindowInsetsControllerCompat] behavior constants:
|
||||
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_TOUCH] (Deprecated),
|
||||
* [WindowInsetsControllerCompat.BEHAVIOR_DEFAULT] and
|
||||
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE].
|
||||
*/
|
||||
var systemBarsBehavior: Int
|
||||
|
||||
/**
|
||||
* Property which holds the status bar visibility. If set to true, show the status bar,
|
||||
* otherwise hide the status bar.
|
||||
*/
|
||||
var isStatusBarVisible: Boolean
|
||||
|
||||
/**
|
||||
* Property which holds the navigation bar visibility. If set to true, show the navigation bar,
|
||||
* otherwise hide the navigation bar.
|
||||
*/
|
||||
var isNavigationBarVisible: Boolean
|
||||
|
||||
/**
|
||||
* Property which holds the status & navigation bar visibility. If set to true, show both bars,
|
||||
* otherwise hide both bars.
|
||||
*/
|
||||
var isSystemBarsVisible: Boolean
|
||||
get() = isNavigationBarVisible && isStatusBarVisible
|
||||
set(value) {
|
||||
isStatusBarVisible = value
|
||||
isNavigationBarVisible = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Property which holds whether the status bar icons + content are 'dark' or not.
|
||||
*/
|
||||
var statusBarDarkContentEnabled: Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers a [SystemUiController] for the given [window].
|
||||
*
|
||||
* If no [window] is provided, an attempt to find the correct [Window] is made.
|
||||
*
|
||||
* First, if the [LocalView]'s parent is a [DialogWindowProvider], then that dialog's [Window] will
|
||||
* be used.
|
||||
*
|
||||
* Second, we attempt to find [Window] for the [Activity] containing the [LocalView].
|
||||
*
|
||||
* If none of these are found (such as may happen in a preview), then the functionality of the
|
||||
* returned [SystemUiController] will be degraded, but won't throw an exception.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSystemUiController(
|
||||
window: Window? = findWindow(),
|
||||
): SystemUiController {
|
||||
val view = LocalView.current
|
||||
return remember(view, window) { AndroidSystemUiController(view, window) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun findWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
?: LocalView.current.context.findWindow()
|
||||
|
||||
private tailrec fun Context.findWindow(): Window? = when (this) {
|
||||
is Activity -> window
|
||||
is ContextWrapper -> baseContext.findWindow()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper class for setting the navigation and status bar colors for a [View], gracefully
|
||||
* degrading behavior based upon API level.
|
||||
*
|
||||
* Typically you would use [rememberSystemUiController] to remember an instance of this.
|
||||
*/
|
||||
internal class AndroidSystemUiController(
|
||||
private val view: View,
|
||||
window: Window?,
|
||||
) : SystemUiController {
|
||||
private val windowInsetsController = window?.let {
|
||||
WindowCompat.getInsetsController(it, view)
|
||||
}
|
||||
|
||||
override var systemBarsBehavior: Int
|
||||
get() = windowInsetsController?.systemBarsBehavior ?: 0
|
||||
set(value) {
|
||||
windowInsetsController?.systemBarsBehavior = value
|
||||
}
|
||||
|
||||
override var isStatusBarVisible: Boolean
|
||||
get() {
|
||||
return ViewCompat.getRootWindowInsets(view)
|
||||
?.isVisible(WindowInsetsCompat.Type.statusBars()) == true
|
||||
}
|
||||
set(value) {
|
||||
if (value) {
|
||||
windowInsetsController?.show(WindowInsetsCompat.Type.statusBars())
|
||||
} else {
|
||||
windowInsetsController?.hide(WindowInsetsCompat.Type.statusBars())
|
||||
}
|
||||
}
|
||||
|
||||
override var isNavigationBarVisible: Boolean
|
||||
get() {
|
||||
return ViewCompat.getRootWindowInsets(view)
|
||||
?.isVisible(WindowInsetsCompat.Type.navigationBars()) == true
|
||||
}
|
||||
set(value) {
|
||||
if (value) {
|
||||
windowInsetsController?.show(WindowInsetsCompat.Type.navigationBars())
|
||||
} else {
|
||||
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
|
||||
}
|
||||
}
|
||||
|
||||
override var statusBarDarkContentEnabled: Boolean
|
||||
get() = windowInsetsController?.isAppearanceLightStatusBars == true
|
||||
set(value) {
|
||||
windowInsetsController?.isAppearanceLightStatusBars = value
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,6 @@ fun WebView(
|
||||
is WebContent.Url -> {
|
||||
wv.loadUrl(content.url, content.additionalHttpHeaders)
|
||||
}
|
||||
|
||||
is WebContent.Data -> {
|
||||
wv.loadDataWithBaseURL(
|
||||
content.baseUrl,
|
||||
@@ -190,14 +189,12 @@ fun WebView(
|
||||
content.historyUrl,
|
||||
)
|
||||
}
|
||||
|
||||
is WebContent.Post -> {
|
||||
wv.postUrl(
|
||||
content.url,
|
||||
content.postData,
|
||||
)
|
||||
}
|
||||
|
||||
is WebContent.NavigatorOnly -> {
|
||||
// NO-OP
|
||||
}
|
||||
@@ -502,11 +499,9 @@ class WebViewNavigator(private val coroutineScope: CoroutineScope) {
|
||||
event.encoding,
|
||||
event.historyUrl,
|
||||
)
|
||||
|
||||
is NavigationEvent.LoadUrl -> {
|
||||
loadUrl(event.url, event.additionalHttpHeaders)
|
||||
}
|
||||
|
||||
is NavigationEvent.PostUrl -> {
|
||||
postUrl(event.url, event.postData)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import android.content.Context
|
||||
import android.os.StrictMode
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.runtime.Composer
|
||||
import androidx.compose.runtime.tooling.ComposeStackTraceMode
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil3.EventListener
|
||||
@@ -29,13 +31,25 @@ import coil3.asImage
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import coil3.serviceLoaderEnabled
|
||||
import coil3.util.DebugLogger
|
||||
import com.ehviewer.core.database.SearchDatabase
|
||||
import com.ehviewer.core.database.roomDb
|
||||
import com.ehviewer.core.files.deleteContent
|
||||
import com.ehviewer.core.ui.util.initSETConnection
|
||||
import com.ehviewer.core.util.isAtLeastO
|
||||
import com.ehviewer.core.util.isAtLeastP
|
||||
import com.ehviewer.core.util.isAtLeastS
|
||||
import com.ehviewer.core.util.launchIO
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.ehviewer.core.util.withUIContext
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.coil.AnimatedWebPDecoder
|
||||
import com.hippo.ehviewer.coil.CropBorderInterceptor
|
||||
import com.hippo.ehviewer.coil.DetectBorderInterceptor
|
||||
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
|
||||
@@ -43,9 +57,7 @@ import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
|
||||
import com.hippo.ehviewer.coil.MapExtraInfoInterceptor
|
||||
import com.hippo.ehviewer.coil.MergeInterceptor
|
||||
import com.hippo.ehviewer.coil.QrCodeInterceptor
|
||||
import com.hippo.ehviewer.coil.limitConcurrency
|
||||
import com.hippo.ehviewer.dailycheck.checkDawn
|
||||
import com.hippo.ehviewer.dao.SearchDatabase
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
import com.hippo.ehviewer.ktbuilder.diskCache
|
||||
@@ -53,24 +65,15 @@ import com.hippo.ehviewer.ktbuilder.imageLoader
|
||||
import com.hippo.ehviewer.ktor.Cronet
|
||||
import com.hippo.ehviewer.ktor.configureClient
|
||||
import com.hippo.ehviewer.ktor.configureCommon
|
||||
import com.hippo.ehviewer.ktor.isCronetAvailable
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.ui.lockObserver
|
||||
import com.hippo.ehviewer.ui.screen.detailCache
|
||||
import com.hippo.ehviewer.ui.tools.dataStateFlow
|
||||
import com.hippo.ehviewer.ui.tools.initSETConnection
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.CrashHandler
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.OSUtils
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import com.hippo.ehviewer.util.isAtLeastSExtension7
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -80,43 +83,54 @@ import logcat.LogPriority
|
||||
import logcat.LogcatLogger
|
||||
import logcat.asLog
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.arch.room.roomDb
|
||||
import splitties.init.appCtx
|
||||
|
||||
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
|
||||
private val lifecycleScope = lifecycle.coroutineScope
|
||||
|
||||
class EhApplication :
|
||||
Application(),
|
||||
SingletonImageLoader.Factory {
|
||||
override fun onCreate() {
|
||||
class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
override fun onCreate() = with(lifecycleScope) {
|
||||
initSETConnection()
|
||||
// Initialize Settings on first access
|
||||
lifecycleScope.launchIO {
|
||||
val mode = Settings.theme
|
||||
launchIO {
|
||||
val mode = Settings.theme.value
|
||||
if (!isAtLeastS) {
|
||||
withUIContext {
|
||||
AppCompatDelegate.setDefaultNightMode(mode)
|
||||
}
|
||||
}
|
||||
if (!LogcatLogger.isInstalled && Settings.saveCrashLog) {
|
||||
LogcatLogger.install(AndroidLogcatLogger(LogPriority.VERBOSE))
|
||||
LogcatLogger.loggers += AndroidLogcatLogger(LogPriority.VERBOSE)
|
||||
Settings.saveCrashLog.valueFlow().collect {
|
||||
if (it) {
|
||||
LogcatLogger.install()
|
||||
} else {
|
||||
LogcatLogger.uninstall()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(lockObserver)
|
||||
CrashHandler.install()
|
||||
super.onCreate()
|
||||
System.loadLibrary("ehviewer")
|
||||
lifecycleScope.launchIO {
|
||||
launchUI { FavouriteStatusRouter.collect { (gid, slot) -> detailCache[gid]?.favoriteSlot = slot } }
|
||||
launch { EhTagDatabase }
|
||||
launch {
|
||||
FavouriteStatusRouter.collect { info ->
|
||||
detailCache[info.gid]?.apply {
|
||||
favoriteSlot = info.favoriteSlot
|
||||
favoriteName = info.favoriteName
|
||||
favoriteNote = info.favoriteNote
|
||||
}
|
||||
}
|
||||
}
|
||||
launchIO {
|
||||
EhTagDatabase.launchUpdate()
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
launch { EhDB }
|
||||
launchIO { dataStateFlow.value }
|
||||
launchIO { OSUtils.totalMemory }
|
||||
launch { dataStateFlow.value }
|
||||
launch { OSUtils.totalMemory }
|
||||
launch {
|
||||
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode.key !in Settings.prefs) {
|
||||
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode !in Settings.snapshot()) {
|
||||
Settings.downloadFilterMode.value = DownloadsFilterMode.CUSTOM.flag
|
||||
}
|
||||
initialized = true
|
||||
DownloadManager.readMetadataFromLocal()
|
||||
}
|
||||
launch {
|
||||
@@ -124,12 +138,15 @@ class EhApplication :
|
||||
FileUtils.cleanupDirectory(AppConfig.externalParseErrorDir)
|
||||
}
|
||||
launch { cleanupDownload() }
|
||||
if (Settings.requestNews) {
|
||||
if (Settings.requestNews.value) {
|
||||
launch { checkDawn() }
|
||||
}
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.enableDefaults()
|
||||
Composer.setDiagnosticStackTraceMode(ComposeStackTraceMode.SourceInformation)
|
||||
} else {
|
||||
Composer.setDiagnosticStackTraceMode(ComposeStackTraceMode.Auto)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,37 +164,39 @@ class EhApplication :
|
||||
}
|
||||
|
||||
private fun clearTempDir() {
|
||||
var dir = AppConfig.tempDir
|
||||
if (null != dir) {
|
||||
FileUtils.deleteContent(dir)
|
||||
}
|
||||
dir = AppConfig.externalTempDir
|
||||
if (null != dir) {
|
||||
FileUtils.deleteContent(dir)
|
||||
}
|
||||
AppConfig.tempDir.deleteContent()
|
||||
AppConfig.externalTempDir?.deleteContent()
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: Context) = context.imageLoader {
|
||||
interceptorCoroutineContext(Dispatchers.Default)
|
||||
components {
|
||||
serviceLoaderEnabled(false)
|
||||
add(NetworkFetcher.Factory({ ktorClient.limitConcurrency() }) { ConnectivityChecker.ONLINE })
|
||||
add(
|
||||
KtorNetworkFetcherFactory(
|
||||
httpClient = { ktorClient },
|
||||
connectivityChecker = { ConnectivityChecker.ONLINE },
|
||||
),
|
||||
)
|
||||
add(MergeInterceptor)
|
||||
add(DownloadThumbInterceptor)
|
||||
if (isAtLeastO) {
|
||||
add(HardwareBitmapInterceptor)
|
||||
} else {
|
||||
allowRgb565(true)
|
||||
}
|
||||
add(CropBorderInterceptor)
|
||||
add(DetectBorderInterceptor)
|
||||
add(QrCodeInterceptor)
|
||||
add(MapExtraInfoInterceptor)
|
||||
add(AnimatedWebPDecoder.Factory)
|
||||
if (isAtLeastP) {
|
||||
add(AnimatedImageDecoder.Factory(false))
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
diskCache { imageCache }
|
||||
diskCache { thumbCache }
|
||||
crossfade(300)
|
||||
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
|
||||
if (drawable != null) error(drawable.asImage(true))
|
||||
@@ -195,10 +214,14 @@ class EhApplication :
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
var initialized = false
|
||||
private set
|
||||
|
||||
val ktorClient by lazy {
|
||||
if (isAtLeastSExtension7 && Settings.enableCronet) {
|
||||
if (isCronetAvailable && Settings.enableQuic.value) {
|
||||
HttpClient(Cronet) {
|
||||
engine { configureClient() }
|
||||
engine { configureClient(Settings.enableQuic.value) }
|
||||
configureCommon()
|
||||
}
|
||||
} else {
|
||||
@@ -215,10 +238,10 @@ class EhApplication :
|
||||
}
|
||||
}
|
||||
|
||||
val imageCache by lazy {
|
||||
val thumbCache by lazy {
|
||||
diskCache {
|
||||
directory(appCtx.cacheDir.toOkioPath() / "image_cache")
|
||||
maxSizeBytes(Settings.readCacheSize.coerceIn(320, 5120).toLong() * 1024 * 1024)
|
||||
directory(appCtx.cacheDir.toOkioPath() / "thumb")
|
||||
maxSizeBytes(80L * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,28 +15,31 @@
|
||||
*/
|
||||
package com.hippo.ehviewer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import arrow.fx.coroutines.release
|
||||
import androidx.room.execSQL
|
||||
import androidx.room.useWriterConnection
|
||||
import arrow.fx.coroutines.resource
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadArtist
|
||||
import com.hippo.ehviewer.dao.DownloadDirname
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.DownloadLabel
|
||||
import com.hippo.ehviewer.dao.EhDatabase
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.HistoryInfo
|
||||
import com.hippo.ehviewer.dao.LocalFavoriteInfo
|
||||
import com.hippo.ehviewer.dao.ProgressInfo
|
||||
import com.hippo.ehviewer.dao.QuickSearch
|
||||
import com.hippo.ehviewer.dao.Schema17to18
|
||||
import arrow.fx.coroutines.resourceScope
|
||||
import com.ehviewer.core.data.model.asEntity
|
||||
import com.ehviewer.core.database.EhDatabase
|
||||
import com.ehviewer.core.database.Schema17to18
|
||||
import com.ehviewer.core.database.getDatabasePath
|
||||
import com.ehviewer.core.database.model.DownloadArtist
|
||||
import com.ehviewer.core.database.model.DownloadDirname
|
||||
import com.ehviewer.core.database.model.DownloadInfo
|
||||
import com.ehviewer.core.database.model.DownloadLabel
|
||||
import com.ehviewer.core.database.model.Filter
|
||||
import com.ehviewer.core.database.model.GalleryEntity
|
||||
import com.ehviewer.core.database.model.HistoryInfo
|
||||
import com.ehviewer.core.database.model.LocalFavoriteInfo
|
||||
import com.ehviewer.core.database.model.ProgressInfo
|
||||
import com.ehviewer.core.database.model.QuickSearch
|
||||
import com.ehviewer.core.database.roomDb
|
||||
import com.ehviewer.core.files.delete
|
||||
import com.ehviewer.core.files.sendTo
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.arch.room.roomDb
|
||||
|
||||
object EhDB {
|
||||
private const val DB_NAME = "eh.db"
|
||||
@@ -44,15 +47,15 @@ object EhDB {
|
||||
addMigrations(Schema17to18())
|
||||
}
|
||||
|
||||
private suspend fun putGalleryInfo(galleryInfo: BaseGalleryInfo) {
|
||||
suspend fun putGalleryInfo(galleryInfo: GalleryEntity) {
|
||||
db.galleryDao().upsert(galleryInfo)
|
||||
}
|
||||
|
||||
private suspend fun deleteGalleryInfo(galleryInfo: BaseGalleryInfo) {
|
||||
private suspend fun deleteGalleryInfo(galleryInfo: GalleryEntity) {
|
||||
runCatching { db.galleryDao().delete(galleryInfo) }
|
||||
}
|
||||
|
||||
suspend fun updateGalleryInfo(galleryInfoList: List<BaseGalleryInfo>) {
|
||||
suspend fun updateGalleryInfo(galleryInfoList: List<GalleryEntity>) {
|
||||
db.galleryDao().update(galleryInfoList)
|
||||
}
|
||||
|
||||
@@ -146,6 +149,8 @@ object EhDB {
|
||||
dao.fill(raw.position)
|
||||
}
|
||||
|
||||
suspend fun searchDownloadLabel(keyword: String, limit: Int) = db.downloadLabelDao().search("%$keyword%", limit)
|
||||
|
||||
suspend fun putDownloadArtist(gid: Long, artists: List<DownloadArtist>) {
|
||||
if (artists.isNotEmpty()) {
|
||||
val dao = db.downloadArtistDao()
|
||||
@@ -154,12 +159,12 @@ object EhDB {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeLocalFavorites(galleryInfo: BaseGalleryInfo) {
|
||||
suspend fun removeLocalFavorites(galleryInfo: GalleryInfo) {
|
||||
db.localFavoritesDao().deleteByKey(galleryInfo.gid)
|
||||
deleteGalleryInfo(galleryInfo)
|
||||
deleteGalleryInfo(galleryInfo.asEntity())
|
||||
}
|
||||
|
||||
suspend fun removeLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
suspend fun removeLocalFavorites(galleryInfoList: Collection<GalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
removeLocalFavorites(it)
|
||||
}
|
||||
@@ -170,12 +175,12 @@ object EhDB {
|
||||
return dao.contains(gid)
|
||||
}
|
||||
|
||||
suspend fun putLocalFavorites(galleryInfo: BaseGalleryInfo) {
|
||||
putGalleryInfo(galleryInfo)
|
||||
suspend fun putLocalFavorites(galleryInfo: GalleryInfo) {
|
||||
putGalleryInfo(galleryInfo.asEntity())
|
||||
db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid))
|
||||
}
|
||||
|
||||
suspend fun putLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
suspend fun putLocalFavorites(galleryInfoList: Collection<GalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
putLocalFavorites(it)
|
||||
}
|
||||
@@ -221,8 +226,8 @@ object EhDB {
|
||||
|
||||
fun searchLocalFav(keyword: String) = db.localFavoritesDao().joinListLazy("*$keyword*")
|
||||
|
||||
suspend fun putHistoryInfo(galleryInfo: BaseGalleryInfo) {
|
||||
putGalleryInfo(galleryInfo)
|
||||
suspend fun putHistoryInfo(galleryInfo: GalleryInfo) {
|
||||
putGalleryInfo(galleryInfo.asEntity())
|
||||
db.historyDao().upsert(HistoryInfo(galleryInfo.gid))
|
||||
}
|
||||
|
||||
@@ -239,7 +244,7 @@ object EhDB {
|
||||
dao.insertOrIgnore(historyInfoList)
|
||||
}
|
||||
|
||||
suspend fun deleteHistoryInfo(galleryInfo: BaseGalleryInfo) {
|
||||
suspend fun deleteHistoryInfo(galleryInfo: GalleryEntity) {
|
||||
val dao = db.historyDao()
|
||||
dao.deleteByKey(galleryInfo.gid)
|
||||
deleteGalleryInfo(galleryInfo)
|
||||
@@ -273,58 +278,61 @@ object EhDB {
|
||||
db.filterDao().update(filter)
|
||||
}
|
||||
|
||||
fun exportDB(context: Context, file: Path) {
|
||||
db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() }
|
||||
val dbFile = context.getDatabasePath(DB_NAME)
|
||||
dbFile.toOkioPath() sendTo file
|
||||
suspend fun exportDB(file: Path) {
|
||||
db.useWriterConnection { conn ->
|
||||
conn.execSQL("PRAGMA wal_checkpoint(FULL)")
|
||||
conn.execSQL("VACUUM")
|
||||
}
|
||||
val dbFile = getDatabasePath(DB_NAME)
|
||||
dbFile sendTo file
|
||||
}
|
||||
|
||||
suspend fun importDB(context: Context, uri: Uri) {
|
||||
suspend fun importDB(file: Path) = resourceScope {
|
||||
val tempDBName = "tmp.db"
|
||||
resource {
|
||||
context.deleteDatabase(tempDBName)
|
||||
val dbFile = getDatabasePath(tempDBName)
|
||||
file sendTo dbFile
|
||||
val oldDB = resource {
|
||||
roomDb<EhDatabase>(tempDBName) {
|
||||
createFromInputStream { context.contentResolver.openInputStream(uri) }
|
||||
addMigrations(Schema17to18())
|
||||
}
|
||||
} release {
|
||||
it.close()
|
||||
context.deleteDatabase(tempDBName)
|
||||
} use { oldDB ->
|
||||
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
|
||||
} release { db ->
|
||||
db.close()
|
||||
dbFile.delete()
|
||||
}
|
||||
|
||||
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
|
||||
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
|
||||
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
|
||||
|
||||
val downloadLabelList = oldDB.downloadLabelDao().list()
|
||||
DownloadManager.addDownloadLabel(downloadLabelList)
|
||||
val downloadLabelList = oldDB.downloadLabelDao().list()
|
||||
DownloadManager.addDownloadLabel(downloadLabelList)
|
||||
|
||||
oldDB.downloadDirnameDao().list().let {
|
||||
importDownloadDirname(it)
|
||||
}
|
||||
oldDB.downloadDirnameDao().list().let {
|
||||
importDownloadDirname(it)
|
||||
}
|
||||
|
||||
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
|
||||
DownloadManager.addDownload(downloadInfoList)
|
||||
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
|
||||
DownloadManager.addDownload(downloadInfoList)
|
||||
|
||||
val historyInfoList = oldDB.historyDao().list()
|
||||
importHistoryInfo(historyInfoList)
|
||||
val historyInfoList = oldDB.historyDao().list()
|
||||
importHistoryInfo(historyInfoList)
|
||||
|
||||
val quickSearchList = oldDB.quickSearchDao().list()
|
||||
val currentQuickSearchList = db.quickSearchDao().list()
|
||||
val offset = currentQuickSearchList.size
|
||||
val importList = quickSearchList.filter { newQS ->
|
||||
currentQuickSearchList.none { it.name == newQS.name }
|
||||
}.onEachIndexed { index, q -> q.position = index + offset }
|
||||
importQuickSearch(importList)
|
||||
val quickSearchList = oldDB.quickSearchDao().list()
|
||||
val currentQuickSearchList = db.quickSearchDao().list()
|
||||
val offset = currentQuickSearchList.size
|
||||
val importList = quickSearchList.filter { newQS ->
|
||||
currentQuickSearchList.none { it.name == newQS.name }
|
||||
}.onEachIndexed { index, q ->
|
||||
q.id = null
|
||||
q.position = index + offset
|
||||
}
|
||||
importQuickSearch(importList)
|
||||
|
||||
oldDB.localFavoritesDao().list().let {
|
||||
importLocalFavorites(it)
|
||||
}
|
||||
oldDB.localFavoritesDao().list().let {
|
||||
importLocalFavorites(it)
|
||||
}
|
||||
|
||||
val filterList = oldDB.filterDao().list()
|
||||
val currentFilterList = db.filterDao().list()
|
||||
filterList.forEach {
|
||||
if (it !in currentFilterList) addFilter(it)
|
||||
}
|
||||
oldDB.filterDao().list().forEach {
|
||||
addFilter(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.hippo.ehviewer
|
||||
|
||||
import com.hippo.ehviewer.client.hosts
|
||||
import com.hippo.ehviewer.client.hostsDsl
|
||||
|
||||
val builtInHosts = hostsDsl {
|
||||
hosts("exhentai.org") {
|
||||
"178.175.128.251" blockedInCN false
|
||||
"178.175.128.252" blockedInCN false
|
||||
"178.175.128.253" blockedInCN false
|
||||
"178.175.128.254" blockedInCN false
|
||||
"178.175.129.251" blockedInCN false
|
||||
"178.175.129.252" blockedInCN false
|
||||
"178.175.129.253" blockedInCN false
|
||||
"178.175.129.254" blockedInCN false
|
||||
"178.175.132.19" blockedInCN false
|
||||
"178.175.132.20" blockedInCN false
|
||||
"178.175.132.21" blockedInCN false
|
||||
"178.175.132.22" blockedInCN false
|
||||
}
|
||||
hosts("api.github.com") {
|
||||
"140.82.114.5" blockedInCN false
|
||||
"140.82.114.6" blockedInCN false
|
||||
"140.82.116.5" blockedInCN false
|
||||
"140.82.116.6" blockedInCN false
|
||||
"140.82.121.5" blockedInCN true
|
||||
"140.82.121.6" blockedInCN true
|
||||
"20.200.245.245" blockedInCN false
|
||||
"20.201.28.148" blockedInCN false
|
||||
"20.205.243.168" blockedInCN false
|
||||
"20.26.156.210" blockedInCN false
|
||||
"20.27.177.116" blockedInCN false
|
||||
"20.87.245.6" blockedInCN false
|
||||
"4.237.22.34" blockedInCN false
|
||||
}
|
||||
hosts("github.com") {
|
||||
"140.82.113.4" blockedInCN false
|
||||
"140.82.114.3" blockedInCN false
|
||||
"140.82.116.4" blockedInCN false
|
||||
"140.82.121.3" blockedInCN true
|
||||
"140.82.121.4" blockedInCN true
|
||||
"20.200.245.247" blockedInCN false
|
||||
"20.201.28.151" blockedInCN false
|
||||
"20.205.243.166" blockedInCN false
|
||||
"20.26.156.215" blockedInCN false
|
||||
"20.27.177.113" blockedInCN false
|
||||
"20.87.245.0" blockedInCN false
|
||||
"4.237.22.38" blockedInCN false
|
||||
}
|
||||
hosts("raw.githubusercontent.com") {
|
||||
"185.199.108.133" blockedInCN false
|
||||
"185.199.109.133" blockedInCN false
|
||||
"185.199.110.133" blockedInCN false
|
||||
"185.199.111.133" blockedInCN false
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.ehviewer.core.network.EhCookieStore
|
||||
import com.ehviewer.core.preferences.DataStorePreferences
|
||||
import com.ehviewer.core.preferences.PrefDelegate
|
||||
import com.ehviewer.core.preferences.edit
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
import com.hippo.ehviewer.download.SortMode
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import java.util.Locale
|
||||
@@ -25,47 +27,12 @@ import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapMerge
|
||||
import kotlinx.coroutines.flow.map
|
||||
import splitties.preferences.BoolPref
|
||||
import splitties.preferences.DataStorePreferences
|
||||
import splitties.preferences.FloatPref
|
||||
import splitties.preferences.IntPref
|
||||
import splitties.preferences.LongPref
|
||||
import splitties.preferences.PrefDelegate
|
||||
import splitties.preferences.StringOrNullPref
|
||||
import splitties.preferences.StringPref
|
||||
import splitties.preferences.StringSetOrNullPref
|
||||
import splitties.preferences.StringSetPref
|
||||
import splitties.preferences.edit
|
||||
|
||||
@Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
|
||||
fun <T> PrefDelegate<T>.getValue(): T = when (this) {
|
||||
is BoolPref -> value
|
||||
is IntPref -> value
|
||||
is FloatPref -> value
|
||||
is LongPref -> value
|
||||
is StringPref -> value
|
||||
is StringOrNullPref -> value
|
||||
is StringSetPref -> value
|
||||
is StringSetOrNullPref -> value
|
||||
} as T
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> PrefDelegate<T>.setValue(newValue: T) = when (this) {
|
||||
is BoolPref -> value = newValue as Boolean
|
||||
is IntPref -> value = newValue as Int
|
||||
is FloatPref -> value = newValue as Float
|
||||
is LongPref -> value = newValue as Long
|
||||
is StringPref -> value = newValue as String
|
||||
is StringOrNullPref -> value = newValue as? String
|
||||
is StringSetPref -> value = newValue as Set<String?>
|
||||
is StringSetOrNullPref -> value = newValue as? Set<String?>
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @DisallowComposableCalls (T) -> R): State<R> {
|
||||
val flow = remember { valueFlow().map { transform(it) } }
|
||||
val init = getValue()
|
||||
val init = value
|
||||
return flow.collectAsState(transform(init))
|
||||
}
|
||||
|
||||
@@ -73,32 +40,28 @@ inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @Disallo
|
||||
@Composable
|
||||
fun <T> PrefDelegate<T>.collectAsState(): State<T> {
|
||||
val flow = remember { valueFlow() }
|
||||
val init = getValue()
|
||||
val init = value
|
||||
return flow.collectAsState(init)
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
|
||||
val flow = remember { valueFlow() }
|
||||
val init = getValue()
|
||||
val readOnly = flow.collectAsState(init)
|
||||
val readOnly = collectAsState()
|
||||
return remember {
|
||||
object : MutableState<T> {
|
||||
override var value: T
|
||||
get() = readOnly.value
|
||||
set(value) {
|
||||
setValue(value)
|
||||
this@asMutableState.value = value
|
||||
}
|
||||
override fun component1() = TODO()
|
||||
override fun component2() = TODO()
|
||||
override fun component1() = readOnly.value
|
||||
override fun component2() = this@asMutableState::value::set
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Settings : DataStorePreferences(null) {
|
||||
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
|
||||
|
||||
@Suppress("ktlint:standard:backing-property-naming")
|
||||
private val _favFlow = MutableSharedFlow<Unit>()
|
||||
val favChangesFlow = _favFlow.debounce(1000)
|
||||
@@ -106,75 +69,86 @@ object Settings : DataStorePreferences(null) {
|
||||
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
|
||||
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
|
||||
|
||||
val gallerySite = intPref("gallery_site_2", 0).observed { updateWhenGallerySiteChanges() }
|
||||
// Eh
|
||||
val gallerySite = intPref("gallery_site_2", 0).observed(::updateWhenGallerySiteChanges)
|
||||
val defaultFavSlot = intPref("default_favorite_slot", -2)
|
||||
val theme = intPref("theme_2", -1).observed(::updateWhenThemeChanges)
|
||||
val blackDarkTheme = boolPref("black_dark_theme", false)
|
||||
val harmonizeCategoryColor = boolPref("harmonize_category_color", true)
|
||||
val launchPage = intPref("launch_page_2", 0)
|
||||
val listMode = intPref("list_mode_2", 0)
|
||||
val listThumbSize = intPref("list_tile_size", 40)
|
||||
val detailSize = intPref("detail_size_2", 0)
|
||||
val thumbColumns = intPref("thumb_columns", 3)
|
||||
val listThumbSize = intPref("list_tile_size", 40)
|
||||
val showGalleryPages = boolPref("show_gallery_pages", true)
|
||||
val showReadingProgress = boolPref("show_reading_progress", false)
|
||||
val showVoteStatus = boolPref("show_vote_status", false)
|
||||
val showComments = boolPref("show_gallery_comments", true)
|
||||
val commentThreshold = intPref("comment_threshold", -100)
|
||||
val showTagTranslations = boolPref("show_tag_translations", false).observed(::updateWhenTagTranslationChanges)
|
||||
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
|
||||
val showJpnTitle = boolPref("show_jpn_title", false)
|
||||
val requestNews = boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
|
||||
val hideHvEvents = boolPref("hide_hv_events", false)
|
||||
|
||||
// Download
|
||||
val mediaScan = boolPref("media_scan", false).observed(::updateWhenKeepMediaStatusChanges)
|
||||
val multiThreadDownload = intPref("download_thread_2", 3)
|
||||
val downloadDelay = intPref("download_delay_3", 1000)
|
||||
val timeoutSpeed = intPref("timeout_speed_level", 6)
|
||||
val preloadImage = intPref("preload_image_2", 5)
|
||||
val downloadOriginImage = boolPref("download_origin_image", false)
|
||||
val saveAsCbz = boolPref("save_as_cbz", false)
|
||||
val archiveMetadata = boolPref("archive_metadata", true)
|
||||
|
||||
// Privacy
|
||||
val security = boolPref("require_unlock", false)
|
||||
val securityDelay = intPref("require_unlock_delay", 0)
|
||||
val enabledSecurity = boolPref("enable_secure", false)
|
||||
|
||||
// Advanced
|
||||
val saveParseErrorBody = boolPref("save_parse_error_body", true)
|
||||
val saveCrashLog = boolPref("save_crash_log", true)
|
||||
val readCacheSize = intPref("read_cache_size_2", 640)
|
||||
val enableCronet = boolPref("enable_cronet", true)
|
||||
val enableQuic = boolPref("enable_quic", false)
|
||||
val hardwareBitmapThreshold = intPref("hardware_bitmap_threshold", 16384)
|
||||
val preloadThumbAggressively = boolPref("preload_thumb_aggressively", false)
|
||||
val animateItems = boolPref("animate_items", true)
|
||||
val desktopSite = boolPref("desktop_site", true)
|
||||
|
||||
// About
|
||||
val backupBeforeUpdate = boolPref("backup_before_update", false)
|
||||
val useCIUpdateChannel = boolPref("ci_update_channel", BuildConfig.SNAPSHOT)
|
||||
val updateIntervalDays = intPref("update_interval_days", 0)
|
||||
|
||||
// Misc
|
||||
val languageFilter = intPref("language_filter", -1)
|
||||
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
|
||||
val downloadFilterMode = intPref("download_filter_mode", DownloadsFilterMode.Default.flag)
|
||||
|
||||
// Enabled by default as some ExHentai thumbnail servers do not support HTTP/2
|
||||
val forceEhThumb = boolPref("force_eh_thumb", true)
|
||||
val hasSignedIn = boolPref("has_signed_in", EhCookieStore.hasSignedIn())
|
||||
val needSignIn = boolPref("need_sign_in", true)
|
||||
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
|
||||
val blackDarkTheme = boolPref("black_dark_theme", false)
|
||||
val gridView = boolPref("grid_view", false)
|
||||
val showGalleryPages = boolPref("show_gallery_pages", true)
|
||||
val qSSaveProgress = boolPref("qs_save_progress", true)
|
||||
val security = boolPref("require_unlock", false)
|
||||
val animateItems = boolPref("animate_items", true)
|
||||
val displayName = stringOrNullPref("display_name", null)
|
||||
val avatar = stringOrNullPref("avatar", null)
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
|
||||
val displayName = stringOrNullPref("display_name")
|
||||
val avatar = stringOrNullPref("avatar")
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label")
|
||||
|
||||
var downloadScheme by stringOrNullPref("image_scheme", null)
|
||||
var downloadAuthority by stringOrNullPref("image_authority", null)
|
||||
var downloadPath by stringOrNullPref("image_path", null)
|
||||
var downloadQuery by stringOrNullPref("image_query", null)
|
||||
var downloadFragment by stringOrNullPref("image_fragment", null)
|
||||
var downloadScheme by stringOrNullPref("image_scheme")
|
||||
var downloadAuthority by stringOrNullPref("image_authority")
|
||||
var downloadPath by stringOrNullPref("image_path")
|
||||
var downloadQuery by stringOrNullPref("image_query")
|
||||
var downloadFragment by stringOrNullPref("image_fragment")
|
||||
var archivePasswds by stringSetPref("archive_passwds")
|
||||
var downloadDelay by intPref("download_delay_3", 1000)
|
||||
var multiThreadDownload by intPref("download_thread_2", 3)
|
||||
var preloadImage by intPref("preload_image_2", 5)
|
||||
var downloadTimeout by intPref("download_timeout", 60)
|
||||
var theme by intPref("theme_2", -1).observed { updateWhenThemeChanges() }
|
||||
var readCacheSize by intPref("read_cache_size_2", 640)
|
||||
var launchPage by intPref("launch_page_2", 0)
|
||||
var commentThreshold by intPref("comment_threshold", -101)
|
||||
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
|
||||
var showComments by boolPref("show_gallery_comments", true)
|
||||
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
|
||||
var hideHvEvents by boolPref("hide_hv_events", false)
|
||||
var showJpnTitle by boolPref("show_jpn_title", false)
|
||||
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
|
||||
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
|
||||
var enabledSecurity by boolPref("enable_secure", false)
|
||||
var backupBeforeUpdate by boolPref("backup_before_update", true)
|
||||
var useCIUpdateChannel by boolPref("ci_update_channel", AppConfig.isSnapshot)
|
||||
var mediaScan by boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
|
||||
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
|
||||
var saveParseErrorBody by boolPref("save_parse_error_body", true)
|
||||
var saveCrashLog by boolPref("save_crash_log", true)
|
||||
var removeImageFiles by boolPref("include_pic", true)
|
||||
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
|
||||
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
|
||||
var downloadOriginImage by boolPref("download_origin_image", false)
|
||||
var saveAsCbz by boolPref("save_as_cbz", false)
|
||||
var archiveMetadata by boolPref("archive_metadata", true)
|
||||
var enableCronet by boolPref("enable_cronet", true)
|
||||
var desktopSite by boolPref("desktop_site", false)
|
||||
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
|
||||
var defaultFavSlot by intPref("default_favorite_slot", -2)
|
||||
var securityDelay by intPref("require_unlock_delay", 0)
|
||||
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
|
||||
var requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
|
||||
var updateIntervalDays by intPref("update_interval_days", 7)
|
||||
var lastDawnDays by intPref("last_dawn_days", 0)
|
||||
var recentToplist by stringPref("recent_toplist", "11")
|
||||
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
|
||||
var defaultDownloadLabel by stringOrNullPref("default_download_label")
|
||||
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
|
||||
|
||||
// Reader
|
||||
@@ -184,8 +158,8 @@ object Settings : DataStorePreferences(null) {
|
||||
val colorFilterMode = intPref("color_filter_mode", 0)
|
||||
val customBrightness = boolPref("pref_custom_brightness_key", false)
|
||||
val customBrightnessValue = intPref("custom_brightness_value", 0)
|
||||
val readingMode = intPref("pref_default_reading_mode_key", ReadingModeType.LEFT_TO_RIGHT.flagValue)
|
||||
val orientationMode = intPref("pref_default_orientation_type_key", OrientationType.FREE.flagValue)
|
||||
val readingMode = intPref("pref_default_reading_mode_key", ReadingModeType.DEFAULT.prefValue)
|
||||
val orientationMode = intPref("pref_default_orientation_type_key", OrientationType.DEFAULT.prefValue)
|
||||
val showReaderSeekbar = boolPref("pref_show_reader_seekbar", true)
|
||||
val showPageNumber = boolPref("pref_show_page_number_key", true)
|
||||
val readerTheme = intPref("pref_reader_theme_key", 1)
|
||||
@@ -195,6 +169,8 @@ object Settings : DataStorePreferences(null) {
|
||||
val keepScreenOn = boolPref("pref_keep_screen_on_key", true)
|
||||
val readerLongTapAction = boolPref("reader_long_tap", true)
|
||||
val pageTransitions = boolPref("pref_enable_transitions_key", true)
|
||||
val readWithVolumeKeys = boolPref("reader_volume_keys", false)
|
||||
val readWithVolumeKeysInverted = boolPref("reader_volume_keys_inverted", false)
|
||||
val grayScale = boolPref("pref_grayscale", false)
|
||||
val invertedColors = boolPref("pref_inverted_colors", false)
|
||||
val readerWebtoonNav = intPref("reader_navigation_mode_webtoon", 0)
|
||||
@@ -210,50 +186,44 @@ object Settings : DataStorePreferences(null) {
|
||||
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
|
||||
val stripExtraneousAds = boolPref("strip_extraneous_ads", false)
|
||||
|
||||
val userAgent
|
||||
get() = if (desktopSite) CHROME_USER_AGENT else CHROME_MOBILE_USER_AGENT
|
||||
|
||||
init {
|
||||
if ("CN" == Locale.getDefault().country) {
|
||||
edit {
|
||||
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations = true
|
||||
edit { pref ->
|
||||
if ("CN" == Locale.getDefault().country) {
|
||||
if (showTagTranslations !in pref) pref[showTagTranslations] = true
|
||||
}
|
||||
if (pref[downloadFilterMode] == DownloadsFilterMode.ARTIST.flag && pref[recentDownloadLabel] == null) {
|
||||
pref[recentDownloadLabel] = ""
|
||||
}
|
||||
val orientation = pref[orientationMode]
|
||||
if (OrientationType.entries.none { it.prefValue == orientation }) {
|
||||
pref.remove(orientationMode)
|
||||
}
|
||||
}
|
||||
if (downloadFilterMode.value == DownloadsFilterMode.ARTIST.flag && recentDownloadLabel.value == null) {
|
||||
recentDownloadLabel.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
interface Delegate<R> {
|
||||
val flowGetter: () -> Flow<Unit>
|
||||
fun changesFlow(): Flow<Unit>
|
||||
operator fun getValue(thisRef: Any?, prop: KProperty<*>?): R
|
||||
operator fun setValue(thisRef: Any?, prop: KProperty<*>?, value: R)
|
||||
}
|
||||
|
||||
private fun intArrayPref(key: String, count: Int) = object : Delegate<IntArray> {
|
||||
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
|
||||
|
||||
@Suppress("ktlint:standard:backing-property-naming")
|
||||
private var _value = (0 until count).map { intPref("${key}_$it", 0) }.toTypedArray()
|
||||
override fun getValue(thisRef: Any?, prop: KProperty<*>?): IntArray = _value.map { it.value }.toIntArray()
|
||||
private val delegates = Array(count) { intPref("${key}_$it", 0) }
|
||||
override fun changesFlow(): Flow<Unit> = delegates.asFlow().flatMapMerge { it.changesFlow() }.conflate()
|
||||
override fun getValue(thisRef: Any?, prop: KProperty<*>?) = IntArray(delegates.size) { delegates[it].value }
|
||||
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: IntArray) {
|
||||
check(value.size == count)
|
||||
edit { value.zip(_value) { v, d -> d.value = v } }
|
||||
edit { pref -> value.zip(delegates) { v, d -> pref[d] = v } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stringArrayPref(key: String, count: Int, defMetaValue: String) = object : Delegate<Array<String>> {
|
||||
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
|
||||
|
||||
@Suppress("ktlint:standard:backing-property-naming")
|
||||
private var _value = (0 until count).map { stringPref("${key}_$it", "$defMetaValue $it") }.toTypedArray()
|
||||
override fun getValue(thisRef: Any?, prop: KProperty<*>?): Array<String> = _value.map { it.value }.toTypedArray()
|
||||
private val delegates = Array(count) { stringPref("${key}_$it", "$defMetaValue $it") }
|
||||
override fun changesFlow(): Flow<Unit> = delegates.asFlow().flatMapMerge { it.changesFlow() }.conflate()
|
||||
override fun getValue(thisRef: Any?, prop: KProperty<*>?) = Array(delegates.size) { delegates[it].value }
|
||||
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: Array<String>) {
|
||||
check(value.size == count)
|
||||
edit { value.zip(_value) { v, d -> d.value = v } }
|
||||
edit { pref -> value.zip(delegates) { v, d -> pref[d] = v } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Safari/537.36"
|
||||
private const val CHROME_MOBILE_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
|
||||
@@ -2,74 +2,73 @@ package com.hippo.ehviewer
|
||||
|
||||
import android.app.UiModeManager
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import arrow.core.Either.Companion.catch
|
||||
import com.ehviewer.core.preferences.PrefDelegate
|
||||
import com.ehviewer.core.util.isAtLeastS
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.ehviewer.core.util.withIOContext
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.launch
|
||||
import splitties.init.appCtx
|
||||
import splitties.preferences.PrefDelegate
|
||||
import splitties.systemservices.uiModeManager
|
||||
|
||||
private val collectScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
fun <T, R : PrefDelegate<T>> R.observed(func: (Unit) -> Unit) = apply { collectScope.launch { changesFlow().collect(func) } }
|
||||
fun <T, R : Settings.Delegate<T>> R.observed(func: (Unit) -> Unit) = apply { collectScope.launch { flowGetter().collect(func) } }
|
||||
fun <T, R : Settings.Delegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flowGetter().collect { flow.emit(Unit) } } }
|
||||
fun <T, R : PrefDelegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { changesFlow().collect { flow.emit(Unit) } } }
|
||||
private const val TAG = "SettingsCollector"
|
||||
|
||||
fun updateWhenKeepMediaStatusChanges() {
|
||||
collectScope.launchIO {
|
||||
runCatching {
|
||||
keepNoMediaFileStatus()
|
||||
private val collectScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
fun <T, R : PrefDelegate<T>> R.observed(func: suspend (T) -> Unit) = apply { collectScope.launch { valueFlow().drop(1).collectLatest(func) } }
|
||||
fun <T, R : Settings.Delegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flow.emitAll(changesFlow()) } }
|
||||
fun <T, R : PrefDelegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flow.emitAll(changesFlow()) } }
|
||||
|
||||
suspend fun updateWhenKeepMediaStatusChanges(mediaScan: Boolean) {
|
||||
withIOContext {
|
||||
catch {
|
||||
keepNoMediaFileStatus(mediaScan = mediaScan)
|
||||
}.onLeft {
|
||||
logcat(TAG, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWhenThemeChanges() {
|
||||
collectScope.launch {
|
||||
delay(100) // Avoid recompose being cancelled
|
||||
if (isAtLeastS) {
|
||||
val mode = when (Settings.theme) {
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES
|
||||
else -> UiModeManager.MODE_NIGHT_AUTO
|
||||
}
|
||||
uiModeManager.setApplicationNightMode(mode)
|
||||
suspend fun updateWhenThemeChanges(theme: Int) {
|
||||
delay(100) // Avoid recompose being cancelled
|
||||
if (isAtLeastS) {
|
||||
val mode = when (theme) {
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES
|
||||
else -> UiModeManager.MODE_NIGHT_AUTO
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(Settings.theme)
|
||||
uiModeManager.setApplicationNightMode(mode)
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(theme)
|
||||
}
|
||||
|
||||
fun updateWhenRequestNewsChanges() {
|
||||
updateDailyCheckWork(appCtx)
|
||||
}
|
||||
|
||||
fun updateWhenGallerySiteChanges() {
|
||||
suspend fun updateWhenGallerySiteChanges(gallerySite: Int) {
|
||||
if (Settings.hasSignedIn.value) {
|
||||
collectScope.launchIO {
|
||||
runCatching {
|
||||
EhEngine.getUConfig()
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
catch {
|
||||
EhEngine.getUConfig(gallerySite)
|
||||
}.onLeft {
|
||||
logcat(TAG, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWhenTagTranslationChanges() {
|
||||
collectScope.launchIO {
|
||||
runCatching {
|
||||
EhTagDatabase.update()
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
fun updateWhenTagTranslationChanges(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
EhTagDatabase.launchUpdate()
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,7 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
|
||||
// Normal Preview (v2): https://*.hath.network/c(m|1|2)/[timed token]/[gid]-[index].(jpg|webp)
|
||||
// ExHentai Large Preview (v1 Cover): https://s.exhentai.org/t/***
|
||||
@@ -26,7 +25,6 @@ import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
|
||||
const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
|
||||
const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
|
||||
const val URL_SIGNATURE_THUMB_NORMAL = ".hath.network/c"
|
||||
private const val URL_PREFIX_V1_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
|
||||
private val V2PreviewKeyRegex = Regex("/c([m12]/)[^/]+/(\\d+-\\d+)")
|
||||
|
||||
@@ -40,20 +38,15 @@ fun getV2PreviewKey(url: String) = "$".plus(
|
||||
} ?: url,
|
||||
)
|
||||
|
||||
val String.isV2PreviewKey
|
||||
get() = startsWith('$')
|
||||
|
||||
val GalleryInfo.thumbUrl
|
||||
get() = thumbKey!!.let {
|
||||
if (it.endsWith("webp")) {
|
||||
if (useExThumb) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
|
||||
} else {
|
||||
v1ThumbPrefix
|
||||
} + it
|
||||
}
|
||||
get() = keyToUrl(thumbKey!!)
|
||||
|
||||
val v1ThumbPrefix
|
||||
get() = if (useExThumb) URL_PREFIX_V1_THUMB_EX else URL_PREFIX_THUMB_E
|
||||
|
||||
private val useExThumb
|
||||
get() = EhUtils.isExHentai && !Settings.forceEhThumb.value
|
||||
fun keyToUrl(key: String) = if (key.startsWith("https:")) {
|
||||
key
|
||||
} else {
|
||||
if (key.endsWith("webp")) {
|
||||
if (EhUtils.isExHentai) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
|
||||
} else {
|
||||
if (EhUtils.isExHentai) URL_PREFIX_V1_THUMB_EX else URL_PREFIX_THUMB_E
|
||||
} + key
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.ehviewer.core.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.builtInHosts
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import okhttp3.AsyncDns
|
||||
import okhttp3.Dns
|
||||
import okhttp3.android.AndroidAsyncDns
|
||||
|
||||
private typealias HostsMap = MutableMap<String, List<InetAddress>>
|
||||
|
||||
const val CFSUFFIX = ".cdn.cloudflare.net"
|
||||
|
||||
val cloudflaredDomains = listOf(
|
||||
"e-hentai.org",
|
||||
"api.e-hentai.org",
|
||||
"upload.e-hentai.org",
|
||||
"forums.e-hentai.org",
|
||||
"exhentai.org",
|
||||
"s.exhentai.org",
|
||||
"testingcf.jsdelivr.net",
|
||||
)
|
||||
|
||||
val dFEnabledDomains = listOf(
|
||||
"github.com", "api.github.com",
|
||||
"ehgt.org", "gt0.ehgt.org", "gt1.ehgt.org", "gt2.ehgt.org", "gt3.ehgt.org", "ul.ehgt.org",
|
||||
"e-hentai.org", "api.e-hentai.org", "forums.e-hentai.org", "repo.e-hentai.org", "upload.e-hentai.org",
|
||||
"exhentai.org", "s.exhentai.org",
|
||||
)
|
||||
|
||||
val dohSkipDomains = listOf(
|
||||
"www.recaptcha.net",
|
||||
"www.gstatic.cn",
|
||||
"www.gstatic.com",
|
||||
)
|
||||
|
||||
fun hostsDsl(builder: HostsMap.() -> Unit): HostsMap = mutableMapOf<String, List<InetAddress>>().apply(builder)
|
||||
|
||||
fun interface HostMapBuilder {
|
||||
infix fun String.blockedInCN(boolean: Boolean)
|
||||
}
|
||||
|
||||
fun HostsMap.hosts(vararg hosts: String, builder: HostMapBuilder.() -> Unit) = apply {
|
||||
hosts.forEach { host ->
|
||||
fun String.toInetAddress() = InetAddress.getByName(this).let { InetAddress.getByAddress(host, it.address) }
|
||||
mutableListOf<InetAddress>().apply {
|
||||
HostMapBuilder { if (!it) add(toInetAddress()) }.apply(builder)
|
||||
put(host, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val systemDns = if (isAtLeastQ) AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6) else Dns.SYSTEM
|
||||
|
||||
object EhDns : Dns {
|
||||
override fun lookup(hostname: String): List<InetAddress> = when {
|
||||
(hostname in "exhentai.org") ->
|
||||
builtInHosts[hostname] ?: systemDns.lookup("s.exhentai.org$CFSUFFIX")
|
||||
(hostname in cloudflaredDomains) ->
|
||||
builtInHosts[hostname] ?: systemDns.lookup("$hostname$CFSUFFIX")
|
||||
(hostname in dohSkipDomains) ->
|
||||
systemDns.lookup(hostname)
|
||||
else ->
|
||||
builtInHosts[hostname] ?: systemDns.lookup(hostname)
|
||||
}.shuffled()
|
||||
}
|
||||
@@ -21,14 +21,23 @@ import arrow.core.partially2
|
||||
import arrow.core.right
|
||||
import arrow.fx.coroutines.parMap
|
||||
import arrow.fx.coroutines.parZip
|
||||
import com.ehviewer.core.i18n.R
|
||||
import com.ehviewer.core.model.BaseGalleryInfo
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.network.EhCookieStore
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.fillInfo
|
||||
import com.hippo.ehviewer.client.data.filterComments
|
||||
import com.hippo.ehviewer.client.exception.CloudflareBypassException
|
||||
import com.hippo.ehviewer.client.exception.EhError
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
import com.hippo.ehviewer.client.exception.InsufficientGpException
|
||||
import com.hippo.ehviewer.client.exception.IpBannedException
|
||||
import com.hippo.ehviewer.client.exception.NoHathClientException
|
||||
import com.hippo.ehviewer.client.exception.NoHitsFoundException
|
||||
import com.hippo.ehviewer.client.exception.NotLoggedInException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
@@ -36,11 +45,10 @@ import com.hippo.ehviewer.client.parser.ArchiveParser
|
||||
import com.hippo.ehviewer.client.parser.EventPaneParser
|
||||
import com.hippo.ehviewer.client.parser.FavParserResult
|
||||
import com.hippo.ehviewer.client.parser.FavoritesParser
|
||||
import com.hippo.ehviewer.client.parser.ForumsParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryApiParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryListParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryNotAvailableParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerPTokenParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
|
||||
import com.hippo.ehviewer.client.parser.HomeParser
|
||||
@@ -58,10 +66,12 @@ import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.bodyAsUtf8Text
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.client.statement.request
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.util.moveToByteArray
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import io.ktor.utils.io.readAvailable
|
||||
@@ -72,6 +82,8 @@ import kotlin.system.measureTimeMillis
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.cbor.Cbor
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -79,7 +91,6 @@ import moe.tarsin.coroutines.removeAllSuspend
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import org.jsoup.Jsoup
|
||||
import splitties.init.appCtx
|
||||
|
||||
// https://ehwiki.org/wiki/API#Basics
|
||||
@@ -87,7 +98,9 @@ private const val MAX_REQUEST_SIZE = 25
|
||||
private const val MAX_SEQUENTIAL_REQUESTS = 5
|
||||
private const val REQUEST_INTERVAL = 5000L
|
||||
|
||||
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
private val Url.isLogin get() = segments.firstOrNull() == "bounce_login.php"
|
||||
|
||||
private fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
val dir = AppConfig.externalParseErrorDir ?: return
|
||||
val file = File(dir, ReadableTime.getFilenamableTime() + ".txt")
|
||||
file.sink().buffer().use { sink ->
|
||||
@@ -99,45 +112,53 @@ fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
|
||||
private fun rethrowExactly(response: HttpResponse, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
|
||||
// Don't translate coroutine cancellation
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
if (response.headers["cf-mitigated"] == "challenge") throw CloudflareBypassException()
|
||||
|
||||
// Check sad panda (without panda)
|
||||
val empty = body.fold(
|
||||
{ it.isEmpty() },
|
||||
{ !it.hasRemaining() },
|
||||
)
|
||||
if (empty) {
|
||||
if (EhUtils.isExHentai) {
|
||||
throw EhException("Sad Panda\n(without panda)")
|
||||
val message = if (response.request.url.host == EhUrl.DOMAIN_EX) {
|
||||
"Sad Panda\n(without panda)"
|
||||
} else {
|
||||
throw EhException("IP banned")
|
||||
appCtx.getString(R.string.error_ip_banned)
|
||||
}
|
||||
throw IpBannedException(message)
|
||||
}
|
||||
|
||||
// Check Gallery Not Available
|
||||
body.onLeft {
|
||||
if ("Gallery Not Available - " in it) {
|
||||
val error = GalleryNotAvailableParser.parse(it)
|
||||
if (!error.isNullOrBlank()) {
|
||||
throw EhException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parse error from rust
|
||||
val error = body.getOrNull()?.runCatching {
|
||||
val array = moveToByteArray()
|
||||
rewind()
|
||||
Cbor.decodeFromByteArray<EhError>(array)
|
||||
}?.getOrNull()
|
||||
|
||||
// Check Gallery Not Available - 404
|
||||
if (error is EhError.GalleryUnavailable) throw EhException(error.message)
|
||||
|
||||
// Check bad response code
|
||||
status.ensureSuccess()
|
||||
response.status.ensureSuccess()
|
||||
|
||||
when (error) {
|
||||
EhError.NoHits -> throw NoHitsFoundException()
|
||||
EhError.NoWatched -> throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
EhError.NeedLogin -> throw NotLoggedInException()
|
||||
EhError.NoHathClient -> throw NoHathClientException()
|
||||
EhError.InsufficientFunds -> throw InsufficientFundsException()
|
||||
is EhError.IpBanned -> throw IpBannedException(error.message)
|
||||
is EhError.Error -> throw EhException(error.message)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
if (e is ParseException || e is SerializationException) {
|
||||
body.onLeft { if ("<" !in it) throw EhException(it) }
|
||||
when (val message = e.cause?.message) {
|
||||
"0" -> throw NoHitsFoundException()
|
||||
"1" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
"2" -> throw NotLoggedInException()
|
||||
is String if message.startsWith('3') -> throw EhException(message.drop(1))
|
||||
}
|
||||
if (Settings.saveParseErrorBody) body.saveParseError(e)
|
||||
body.onLeft { if ("<" !in it) throw IpBannedException(it) }
|
||||
if (Settings.saveParseErrorBody.value) body.saveParseError(e)
|
||||
throw EhException(appCtx.getString(R.string.error_parse_error), e)
|
||||
}
|
||||
|
||||
@@ -145,18 +166,20 @@ fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e:
|
||||
throw e
|
||||
}
|
||||
|
||||
val httpContentPool = DirectByteBufferPool(8, 0x80000)
|
||||
private val httpContentPool = DirectByteBufferPool(8, 0x80000)
|
||||
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = executeSafely { response ->
|
||||
private suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = executeSafely { response ->
|
||||
if (response.request.url.isLogin) throw NotLoggedInException()
|
||||
val body = response.bodyAsUtf8Text()
|
||||
runSuspendCatching {
|
||||
block(body)
|
||||
}.onFailure {
|
||||
rethrowExactly(response.status, body.left(), it)
|
||||
rethrowExactly(response, body.left(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = executeSafely { response ->
|
||||
private suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = executeSafely { response ->
|
||||
if (response.request.url.isLogin) throw NotLoggedInException()
|
||||
httpContentPool.useInstance { buffer ->
|
||||
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
|
||||
buffer.flip()
|
||||
@@ -164,18 +187,26 @@ suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: s
|
||||
block(buffer)
|
||||
}.onFailure {
|
||||
buffer.rewind()
|
||||
rethrowExactly(response.status, buffer.right(), it)
|
||||
rethrowExactly(response, buffer.right(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
object EhEngine {
|
||||
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).executeSafely { response ->
|
||||
val location = response.headers["Location"] ?: throw InsufficientFundsException()
|
||||
location.takeIf { "bounce_login" !in it } ?: throw NotLoggedInException()
|
||||
val location = response.headers["Location"] ?: run {
|
||||
val body = response.bodyAsUtf8Text()
|
||||
rethrowExactly(response, body.left(), InsufficientGpException())
|
||||
}
|
||||
if (Url(location).isLogin) throw InsufficientGpException()
|
||||
location
|
||||
}
|
||||
|
||||
suspend fun getTorrentList(url: String, gid: Long, token: String): TorrentResult {
|
||||
suspend fun getPTokenListFromMpv(gid: Long, token: String) = ehRequest(EhUrl.getGalleryMultiPageViewerUrl(gid, token), EhUrl.referer)
|
||||
.fetchUsingAsText(GalleryMultiPageViewerPTokenParser::parse)
|
||||
|
||||
suspend fun getTorrentList(gid: Long, token: String): TorrentResult {
|
||||
val url = EhUrl.getTorrentUrl(gid, token)
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
|
||||
}
|
||||
@@ -185,10 +216,11 @@ object EhEngine {
|
||||
"${id}x$key"
|
||||
}
|
||||
|
||||
suspend fun getArchiveList(url: String, gid: Long, token: String): ArchiveParser.Result {
|
||||
suspend fun getArchiveList(gid: Long, token: String): ArchiveParser.Result {
|
||||
val url = EhUrl.getArchiveUrl(gid, token)
|
||||
val funds = if (EhUtils.isExHentai) getFunds() else null
|
||||
return ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.fetchUsingAsText(ArchiveParser::parse.partially2(funds))
|
||||
.fetchUsingAsByteBuffer(ArchiveParser::parse.partially2(funds))
|
||||
}
|
||||
|
||||
suspend fun getImageLimits() = parZip(
|
||||
@@ -200,14 +232,15 @@ object EhEngine {
|
||||
private suspend fun getFunds() = ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds)
|
||||
|
||||
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
|
||||
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
|
||||
.fetchUsingAsByteBuffer { if (parse) EventPaneParser.parse(this) else null }
|
||||
|
||||
suspend fun getProfile(): ProfileParser.Result {
|
||||
val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsText(ForumsParser::parse)
|
||||
return ehRequest(url, EhUrl.URL_FORUMS).fetchUsingAsText(ProfileParser::parse)
|
||||
val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsByteBuffer(ProfileParser::parseProfileUrl)
|
||||
return ehRequest(url, EhUrl.URL_FORUMS).fetchUsingAsByteBuffer(ProfileParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
|
||||
suspend fun getUConfig(gallerySite: Int) {
|
||||
val url = EhUrl.getUConfigUrl(gallerySite)
|
||||
runSuspendCatching {
|
||||
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
|
||||
}.onFailure { throwable ->
|
||||
@@ -230,18 +263,19 @@ object EhEngine {
|
||||
.apply { galleryInfoList.fillInfo(url, true) }
|
||||
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
|
||||
|
||||
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
|
||||
val eventPane = EventPaneParser.parse(this)
|
||||
if (eventPane != null) {
|
||||
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryDetailParser::parse).run {
|
||||
event?.let {
|
||||
Settings.lastDawnDays = today
|
||||
showEventNotification(eventPane)
|
||||
showEventNotification(it)
|
||||
}
|
||||
detail.apply {
|
||||
// Fill info for database
|
||||
fillInfo()
|
||||
filterComments()
|
||||
}
|
||||
GalleryDetailParser.parse(this)
|
||||
}
|
||||
|
||||
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
|
||||
GalleryDetailParser.parsePreviewList(this)
|
||||
}
|
||||
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryDetailParser::parsePreviews)
|
||||
|
||||
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
|
||||
.apply { galleryInfoList.fillInfo(url) }
|
||||
@@ -275,14 +309,7 @@ object EhEngine {
|
||||
// Ktor does not handle POST redirect, we need to do it manually
|
||||
// https://youtrack.jetbrains.com/issue/KTOR-478
|
||||
val location = response.headers["Location"] ?: url
|
||||
ehRequest(location, url).fetchUsingAsText {
|
||||
val document = Jsoup.parse(this)
|
||||
val elements = document.select("#chd + p")
|
||||
if (elements.isNotEmpty()) {
|
||||
throw EhException(elements[0].text())
|
||||
}
|
||||
GalleryDetailParser.parseComments(document)
|
||||
}
|
||||
ehRequest(location, url).fetchUsingAsByteBuffer(GalleryDetailParser::parseComments)
|
||||
}
|
||||
|
||||
suspend fun modifyFavorites(gid: Long, token: String, dstCat: Int = -1, note: String = "") {
|
||||
@@ -306,12 +333,12 @@ object EhEngine {
|
||||
suspend fun getFavoriteNote(gid: Long, token: String) = ehRequest(EhUrl.getAddFavorites(gid, token), EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.fetchUsingAsText(FavoritesParser::parseNote)
|
||||
|
||||
suspend fun downloadArchive(gid: Long, token: String, res: String, isHAtH: Boolean): String? {
|
||||
val url = EhUrl.getDownloadArchive(gid, token)
|
||||
suspend fun downloadArchive(gid: Long, token: String, res: String, isHath: Boolean): String? {
|
||||
val url = EhUrl.getArchiveUrl(gid, token)
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
val request = ehRequest(url, referer, EhUrl.origin) {
|
||||
formBody {
|
||||
if (isHAtH) {
|
||||
if (isHath) {
|
||||
append("hathdl_xres", res)
|
||||
} else {
|
||||
append("dltype", res)
|
||||
@@ -323,12 +350,12 @@ object EhEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
var result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
|
||||
if (!isHAtH) {
|
||||
var result = request.fetchUsingAsByteBuffer(ArchiveParser::parseArchiveUrl)
|
||||
if (!isHath) {
|
||||
if (result == null) {
|
||||
// Wait for the server to prepare archives
|
||||
delay(1000)
|
||||
result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
|
||||
result = request.fetchUsingAsByteBuffer(ArchiveParser::parseArchiveUrl)
|
||||
if (result == null) throw EhException("Archive unavailable")
|
||||
}
|
||||
return result
|
||||
@@ -392,10 +419,10 @@ object EhEngine {
|
||||
jsonBody {
|
||||
put("method", "gdata")
|
||||
array("gidlist") {
|
||||
it.forEach {
|
||||
it.forEach { info ->
|
||||
addJsonArray {
|
||||
add(it.gid)
|
||||
add(it.token)
|
||||
add(info.gid)
|
||||
add(info.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,7 +454,7 @@ object EhEngine {
|
||||
put("tags", tags)
|
||||
put("vote", vote)
|
||||
}
|
||||
}.fetchUsingAsText(VoteTagParser::parse)
|
||||
}.fetchUsingAsByteBuffer(VoteTagParser::parse)
|
||||
|
||||
suspend fun getGalleryToken(gid: Long, gtoken: String, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import arrow.core.memoize
|
||||
import com.ehviewer.core.database.model.Filter
|
||||
import com.ehviewer.core.database.model.FilterMode
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -55,8 +55,8 @@ object EhFilter : CoroutineScope {
|
||||
suspend fun needTags() = filters.await().any { it.enable && (it.mode == FilterMode.TAG || it.mode == FilterMode.TAG_NAMESPACE) }
|
||||
suspend fun filterTitle(info: GalleryInfo) = anyActive(FilterMode.TITLE) { info.title.orEmpty().contains(it.text, true) }
|
||||
suspend fun filterUploader(info: GalleryInfo) = anyActive(FilterMode.UPLOADER) { it.text == info.uploader }
|
||||
suspend fun filterTag(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG) { matchTag(tag, it.text.lowercase()) } } ?: false
|
||||
suspend fun filterTagNamespace(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG_NAMESPACE) { matchTagNamespace(tag, it.text.lowercase()) } } ?: false
|
||||
suspend fun filterTag(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG) { matchTag(tag, it.text.lowercase()) } } == true
|
||||
suspend fun filterTagNamespace(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG_NAMESPACE) { matchTagNamespace(tag, it.text.lowercase()) } } == true
|
||||
suspend fun filterCommenter(commenter: String) = anyActive(FilterMode.COMMENTER) { it.text == commenter }
|
||||
suspend fun filterComment(comment: String) = anyActive(FilterMode.COMMENT) { regex(it).containsMatchIn(comment) }
|
||||
}
|
||||
@@ -15,11 +15,11 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
|
||||
import com.hippo.ehviewer.util.bodyAsUtf8Text
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.forms.FormDataContent
|
||||
import io.ktor.client.request.header
|
||||
@@ -32,13 +32,13 @@ import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.ParametersBuilder
|
||||
import io.ktor.http.content.TextContent
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.io.Source
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArrayBuilder
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import okio.BufferedSource
|
||||
import kotlinx.serialization.json.io.decodeFromSource
|
||||
|
||||
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
|
||||
|
||||
@@ -47,10 +47,13 @@ suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely
|
||||
it.bodyAsUtf8Text().parseAs<T>()
|
||||
}
|
||||
|
||||
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
|
||||
inline fun <reified T> Source.parseAs(): T = json.decodeFromSource(this)
|
||||
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
|
||||
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
fun HttpRequestBuilder.applyEhConfig(referer: String?, origin: String?) {
|
||||
header("Referer", referer)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.util.Log
|
||||
import com.hippo.ehviewer.Settings
|
||||
import java.net.InetAddress
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
const val EXCEPTIONAL_DOMAIN = "hath.network"
|
||||
private val sslSocketFactory: SSLSocketFactory = SSLContext.getInstance("TLS").apply {
|
||||
init(null, null, null)
|
||||
}.socketFactory
|
||||
|
||||
object EhSSLSocketFactory : SSLSocketFactory() {
|
||||
override fun getDefaultCipherSuites(): Array<String> = sslSocketFactory.defaultCipherSuites
|
||||
override fun getSupportedCipherSuites(): Array<String> = sslSocketFactory.supportedCipherSuites
|
||||
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket = sslSocketFactory.createSocket(s, resolveHost(s, host), port, autoClose) as SSLSocket
|
||||
|
||||
override fun createSocket(host: String, port: Int): Socket = sslSocketFactory.createSocket(host, port)
|
||||
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket = sslSocketFactory.createSocket(host, port, localHost, localPort)
|
||||
override fun createSocket(host: InetAddress, port: Int): Socket = sslSocketFactory.createSocket(host, port)
|
||||
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket = sslSocketFactory.createSocket(address, port, localAddress, localPort)
|
||||
|
||||
private fun resolveHost(socket: Socket, host: String): String = socket.inetAddress.hostAddress.takeIf {
|
||||
host in dFEnabledDomains || EXCEPTIONAL_DOMAIN in host
|
||||
} ?: host
|
||||
}
|
||||
|
||||
fun OkHttpClient.Builder.install(sslSocketFactory: SSLSocketFactory) = apply {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())!!
|
||||
factory.init(null as KeyStore?)
|
||||
val manager = factory.trustManagers!!
|
||||
val trustManager = manager.filterIsInstance<X509TrustManager>().first()
|
||||
sslSocketFactory(sslSocketFactory, trustManager)
|
||||
}
|
||||
@@ -19,218 +19,177 @@ import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import arrow.core.Either
|
||||
import com.ehviewer.core.files.metadataOrNull
|
||||
import com.ehviewer.core.files.read
|
||||
import com.ehviewer.core.files.write
|
||||
import com.ehviewer.core.model.TagNamespace
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import com.hippo.ehviewer.util.bodyAsUtf8Text
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import com.hippo.ehviewer.util.sha1
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import com.hippo.ehviewer.util.utf8
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import java.io.File
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.io.Source
|
||||
import kotlinx.io.writeString
|
||||
import kotlinx.serialization.json.io.encodeToSink
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.BufferedSource
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import splitties.init.appCtx
|
||||
|
||||
object EhTagDatabase : CoroutineScope {
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
private const val NAMESPACE_PREFIX = "n"
|
||||
private val PREFIXES = arrayOf(
|
||||
"-",
|
||||
"~",
|
||||
"tag:",
|
||||
"weak:",
|
||||
)
|
||||
private val PREFIXES = ["-", "~", "tag:", "weak:"]
|
||||
private lateinit var tagGroups: Map<String, Map<String, String>>
|
||||
private val updateLock = Mutex()
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
|
||||
var initialized by mutableStateOf(false)
|
||||
|
||||
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
|
||||
|
||||
private fun internalSuggestFlow(
|
||||
tags: Map<String, String>,
|
||||
keyword: String,
|
||||
translate: Boolean,
|
||||
exactly: Boolean,
|
||||
): Flow<Pair<String?, String>> = flow {
|
||||
if (exactly) {
|
||||
tags[keyword]?.let {
|
||||
emit(Pair(it.takeIf { translate }, keyword))
|
||||
}
|
||||
// Copied from https://github.com/EhTagTranslation/EhSyringe/blob/c0cf88da04081072492829673aca34380b14dae6/src/plugin/suggest.ts#L37
|
||||
// TODO: Optimize
|
||||
private val nsScore = mapOf(
|
||||
TagNamespace.Location.prefix to 10f,
|
||||
TagNamespace.Other.prefix to 10f,
|
||||
TagNamespace.Female.prefix to 9f,
|
||||
TagNamespace.Male.prefix to 8.5f,
|
||||
TagNamespace.Mixed.prefix to 8f,
|
||||
TagNamespace.Parody.prefix to 3.3f,
|
||||
TagNamespace.Character.prefix to 2.8f,
|
||||
TagNamespace.Artist.prefix to 2.5f,
|
||||
TagNamespace.Cosplayer.prefix to 2.4f,
|
||||
TagNamespace.Group.prefix to 2.2f,
|
||||
TagNamespace.Language.prefix to 2f,
|
||||
)
|
||||
|
||||
data class Tag(val tag: String, val hint: String?, val score: Float)
|
||||
|
||||
context(_: Context)
|
||||
fun suggestion(rawKeyword: String, expectTranslate: Boolean): List<Tag> {
|
||||
val keyword = PREFIXES.fold(rawKeyword) { kwd, pfx -> kwd.removePrefix(pfx) }
|
||||
if (!initialized || keyword.isEmpty()) return emptyList()
|
||||
val translate = expectTranslate && translatable
|
||||
val prefix = rawKeyword.dropLast(keyword.length)
|
||||
val ns = keyword.substringBefore(':')
|
||||
val tag = keyword.drop(ns.length + 1)
|
||||
val nsPrefix = TagNamespace.from(ns)?.prefix ?: ns
|
||||
val tags = tagGroups[nsPrefix.takeIf { tag.isNotEmpty() && it != NAMESPACE_PREFIX }]
|
||||
return if (tags != null) {
|
||||
lookup(tags, tag, translate).map { it.copy(tag = "$prefix$nsPrefix:${it.tag}") }
|
||||
} else {
|
||||
if (translate) {
|
||||
tags.forEach { (tag, hint) ->
|
||||
if (tag != keyword &&
|
||||
(tag.containsIgnoreSpace(keyword) || hint.containsIgnoreSpace(keyword))
|
||||
) {
|
||||
emit(Pair(hint, tag))
|
||||
}
|
||||
tagGroups.asSequence().flatMap { (nsPrefix, tags) ->
|
||||
if (nsPrefix != NAMESPACE_PREFIX) {
|
||||
lookup(tags, keyword, translate).map { it.copy(tag = "$prefix$nsPrefix:${it.tag}", score = it.score / (nsScore[nsPrefix] ?: 1f)) }
|
||||
} else {
|
||||
lookup(tags, keyword, translate).map { it.copy(tag = "$prefix${it.tag}:", score = 0f) }
|
||||
}
|
||||
}
|
||||
}.toMutableList().apply { sortBy { it.score } }
|
||||
}
|
||||
|
||||
private fun lookup(tags: Map<String, String>, keyword: String, translate: Boolean) = if (translate) {
|
||||
tags.asSequence().mapNotNull { (tag, hint) ->
|
||||
val score = if (tag.startsWith(keyword, ignoreCase = true)) {
|
||||
tag.length
|
||||
} else {
|
||||
tags.keys.forEach { tag ->
|
||||
if (tag != keyword && tag.containsIgnoreSpace(keyword)) {
|
||||
emit(Pair(null, tag))
|
||||
if (tag.indexOf(" $keyword", ignoreCase = true) != -1) {
|
||||
tag.length * 2
|
||||
} else {
|
||||
when (hint.indexOf(keyword, ignoreCase = true)) {
|
||||
-1 -> 0
|
||||
0 -> hint.length
|
||||
else -> hint.length * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
if (score != 0) Tag(tag, hint, score.toFloat()) else null
|
||||
}
|
||||
}
|
||||
|
||||
// Construct a cold flow for tag database suggestions
|
||||
fun suggestFlow(
|
||||
keyword: String,
|
||||
translate: Boolean,
|
||||
exactly: Boolean = false,
|
||||
): Flow<Pair<String?, String>> = flow {
|
||||
var mKeyword = keyword
|
||||
PREFIXES.forEach { mKeyword = mKeyword.removePrefix(it) }
|
||||
val prefix = keyword.dropLast(mKeyword.length)
|
||||
val namespace = mKeyword.substringBefore(':')
|
||||
val tagKeyword = mKeyword.drop(namespace.length + 1)
|
||||
val namespacePrefix = TagNamespace.from(namespace)?.prefix ?: namespace
|
||||
val tags = tagGroups[namespacePrefix.takeIf { tagKeyword.isNotEmpty() && it != NAMESPACE_PREFIX }]
|
||||
tags?.let {
|
||||
internalSuggestFlow(it, tagKeyword, translate, exactly).collect { (hint, tag) ->
|
||||
emit(Pair(hint, "$prefix$namespacePrefix:$tag"))
|
||||
}
|
||||
} ?: tagGroups.forEach { (namespacePrefix, tags) ->
|
||||
if (namespacePrefix != NAMESPACE_PREFIX) {
|
||||
internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, tag) ->
|
||||
emit(Pair(hint, "$prefix$namespacePrefix:$tag"))
|
||||
}
|
||||
} else {
|
||||
tags.keys.asSequence().mapNotNull { tag ->
|
||||
val score = if (tag.startsWith(keyword, ignoreCase = true)) {
|
||||
tag.length
|
||||
} else {
|
||||
internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, namespacePrefix) ->
|
||||
emit(Pair(hint, "$prefix$namespacePrefix:"))
|
||||
if (tag.indexOf(" $keyword", ignoreCase = true) != -1) {
|
||||
tag.length * 2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
if (score != 0) Tag(tag, null, score.toFloat()) else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.removeSpace(): String = replace(" ", "")
|
||||
private fun metadata(context: Context): Array<String> = context.resources.getStringArray(R.array.tag_translation_metadata)
|
||||
|
||||
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean = removeSpace().contains(other.removeSpace(), ignoreCase)
|
||||
context(ctx: Context)
|
||||
val translatable
|
||||
get() = ctx.resources.getBoolean(R.bool.tag_translatable)
|
||||
|
||||
private fun getMetadata(context: Context): Array<String>? = context.resources.getStringArray(R.array.tag_translation_metadata)
|
||||
.takeIf { it.size == 4 }
|
||||
|
||||
fun isTranslatable(context: Context): Boolean = context.resources.getBoolean(R.bool.tag_translatable)
|
||||
|
||||
private fun getFileContent(file: File): String? = runCatching {
|
||||
file.source().buffer().use { it.readUtf8() }
|
||||
}.getOrNull()
|
||||
|
||||
private fun checkData(sha1: String?, data: File): Boolean = sha1 != null && sha1 == data.toOkioPath().sha1()
|
||||
|
||||
private suspend fun save(client: HttpClient, url: String, file: File) {
|
||||
runCatching {
|
||||
client.prepareGet(url).executeSafely {
|
||||
it.status.ensureSuccess()
|
||||
it.bodyAsChannel().copyTo(file)
|
||||
}
|
||||
}.onFailure {
|
||||
file.delete()
|
||||
}.getOrThrow()
|
||||
private suspend fun fetch(url: String) = ktorClient.prepareGet(url).executeSafely {
|
||||
it.status.ensureSuccess()
|
||||
it.bodyAsUtf8Text()
|
||||
}
|
||||
|
||||
suspend fun update() {
|
||||
fun launchUpdate() = launch {
|
||||
updateLock.withLock {
|
||||
updateInternal()
|
||||
}
|
||||
}
|
||||
|
||||
private fun issueUpdateInMemoryData(file: File? = null) {
|
||||
val dataFile = file ?: getMetadata(appCtx)?.let { metadata ->
|
||||
val dataName = metadata[2]
|
||||
val dir = AppConfig.getFilesDir("tag-translations")
|
||||
File(dir, dataName).takeIf { it.exists() }
|
||||
} ?: return
|
||||
tagGroups = dataFile.source().buffer().use(BufferedSource::parseAs)
|
||||
initialized = true
|
||||
}
|
||||
|
||||
init {
|
||||
launch {
|
||||
updateLock.withLock {
|
||||
runSuspendCatching {
|
||||
issueUpdateInMemoryData()
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
updateInternal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateInternal() {
|
||||
getMetadata(appCtx)?.let { metadata ->
|
||||
val sha1Name = metadata[0]
|
||||
val sha1Url = metadata[1]
|
||||
val dataName = metadata[2]
|
||||
val dataUrl = metadata[3]
|
||||
|
||||
val dir = AppConfig.getFilesDir("tag-translations")
|
||||
checkNotNull(dir)
|
||||
val sha1File = File(dir, sha1Name)
|
||||
val dataFile = File(dir, dataName)
|
||||
|
||||
runSuspendCatching {
|
||||
// Check current sha1 and current data
|
||||
val sha1 = getFileContent(sha1File)
|
||||
if (!checkData(sha1, dataFile)) {
|
||||
FileUtils.delete(sha1File)
|
||||
FileUtils.delete(dataFile)
|
||||
}
|
||||
|
||||
// Save new sha1
|
||||
val tempSha1File = File(dir, "$sha1Name.tmp")
|
||||
save(ktorClient, sha1Url, tempSha1File)
|
||||
val tempSha1 = getFileContent(tempSha1File)
|
||||
|
||||
// Check new sha1 and current sha1
|
||||
if (tempSha1 == sha1) {
|
||||
// The data is the same
|
||||
FileUtils.delete(tempSha1File)
|
||||
return
|
||||
}
|
||||
|
||||
// Save new data
|
||||
val tempDataFile = File(dir, "$dataName.tmp")
|
||||
save(ktorClient, dataUrl, tempDataFile)
|
||||
|
||||
// Check new sha1 and new data
|
||||
if (!checkData(tempSha1, tempDataFile)) {
|
||||
FileUtils.delete(tempSha1File)
|
||||
FileUtils.delete(tempDataFile)
|
||||
return
|
||||
}
|
||||
|
||||
// Replace current sha1 and current data with new sha1 and new data
|
||||
FileUtils.delete(sha1File)
|
||||
FileUtils.delete(dataFile)
|
||||
tempSha1File.renameTo(sha1File)
|
||||
tempDataFile.renameTo(dataFile)
|
||||
|
||||
// Read new EhTagDatabase
|
||||
issueUpdateInMemoryData(dataFile)
|
||||
atomicallyUpdate()
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun atomicallyUpdate() {
|
||||
val (sha1Name, sha1Url, dataName, dataUrl) = metadata(appCtx)
|
||||
val workdir = AppConfig.tagTranslationsDir
|
||||
val sha1File = workdir / sha1Name
|
||||
val dataFile = workdir / dataName
|
||||
|
||||
if (!initialized) {
|
||||
Either.catch {
|
||||
tagGroups = dataFile.read(Source::parseAs)
|
||||
initialized = true
|
||||
|
||||
// Perform only one automatic update (on app startup) per day
|
||||
val lastModified = Instant.fromEpochMilliseconds(dataFile.metadataOrNull()!!.lastModifiedAtMillis!!)
|
||||
if (Clock.System.now() - lastModified < 1.days) return
|
||||
}.onLeft {
|
||||
logcat(it)
|
||||
}
|
||||
}
|
||||
|
||||
val currentSha1 = if (initialized) {
|
||||
Either.catch { sha1File.utf8() }.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val newSha1 = fetch(sha1Url)
|
||||
if (newSha1 == currentSha1) return
|
||||
|
||||
val rawData = fetch(dataUrl)
|
||||
tagGroups = json.parseToJsonElement(rawData).jsonObject["data"]!!.jsonArray.associate { e ->
|
||||
val data = e.jsonObject
|
||||
val namespace = data["namespace"]!!.jsonPrimitive.content
|
||||
val prefix = if (namespace == "rows") NAMESPACE_PREFIX else TagNamespace.from(namespace)!!.prefix!!
|
||||
prefix to data["data"]!!.jsonObject.mapValues { it.value.jsonObject["name"]!!.jsonPrimitive.content }
|
||||
}
|
||||
initialized = true
|
||||
sha1File.write { writeString(newSha1) }
|
||||
dataFile.write { json.encodeToSink(tagGroups, this) }
|
||||
}
|
||||
}
|
||||
@@ -80,12 +80,11 @@ object EhUrl {
|
||||
else -> ORIGIN_E
|
||||
}
|
||||
|
||||
val uConfigUrl: String
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
SITE_E -> URL_UCONFIG_E
|
||||
SITE_EX -> URL_UCONFIG_EX
|
||||
else -> URL_UCONFIG_E
|
||||
}
|
||||
fun getUConfigUrl(gallerySite: Int = Settings.gallerySite.value) = when (gallerySite) {
|
||||
SITE_E -> URL_UCONFIG_E
|
||||
SITE_EX -> URL_UCONFIG_EX
|
||||
else -> URL_UCONFIG_E
|
||||
}
|
||||
|
||||
val myTagsUrl: String
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
@@ -108,14 +107,14 @@ object EhUrl {
|
||||
else -> URL_IMAGE_SEARCH_E
|
||||
}
|
||||
|
||||
fun getGalleryDetailUrl(gid: Long, token: String, index: Int = 0, allComment: Boolean = false) = ehUrl(listOf("g", "$gid", token)) {
|
||||
fun getGalleryDetailUrl(gid: Long, token: String, index: Int = 0, allComment: Boolean = false) = ehUrl(["g", "$gid", token]) {
|
||||
if (index != 0) addQueryParameter("p", "$index")
|
||||
if (allComment) addQueryParameter("hc", "1")
|
||||
}.buildString()
|
||||
|
||||
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl(listOf("mpv", "$gid", token)).buildString()
|
||||
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl(["mpv", "$gid", token]).buildString()
|
||||
|
||||
fun getPageUrl(gid: Long, index: Int, pToken: String, nl: String? = null) = ehUrl(listOf("s", pToken, "$gid-${index + 1}")) {
|
||||
fun getPageUrl(gid: Long, index: Int, pToken: String, nl: String? = null) = ehUrl(["s", pToken, "$gid-${index + 1}"]) {
|
||||
addQueryParameterIfNotBlank("nl", nl)
|
||||
}.buildString()
|
||||
|
||||
@@ -125,11 +124,16 @@ object EhUrl {
|
||||
addQueryParameter("act", "addfav")
|
||||
}.buildString()
|
||||
|
||||
fun getDownloadArchive(gid: Long, token: String) = ehUrl("archiver.php") {
|
||||
fun getArchiveUrl(gid: Long, token: String) = ehUrl("archiver.php") {
|
||||
addQueryParameter("gid", "$gid")
|
||||
addQueryParameter("token", token)
|
||||
}.buildString()
|
||||
|
||||
fun getTorrentUrl(gid: Long, token: String) = ehUrl("gallerytorrents.php") {
|
||||
addQueryParameter("gid", "$gid")
|
||||
addQueryParameter("t", token)
|
||||
}.buildString()
|
||||
|
||||
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
|
||||
|
||||
fun getTrackerUrl(gid: Long, key: String?) = ehUrl(
|
||||
@@ -139,7 +143,7 @@ object EhUrl {
|
||||
}
|
||||
|
||||
inline fun ehUrl(path: String? = null, host: String = EhUrl.domain, builder: ParametersBuilder.() -> Unit) = ehUrl(
|
||||
path = if (path.isNullOrBlank()) emptyList() else listOf(path),
|
||||
path = if (path.isNullOrBlank()) [] else [path],
|
||||
host = host,
|
||||
parameters = Parameters.build(builder),
|
||||
)
|
||||
|
||||
@@ -15,18 +15,36 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Environment
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.net.toUri
|
||||
import arrow.core.memoize
|
||||
import com.ehviewer.core.model.GalleryDetail
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.network.EhCookieStore
|
||||
import com.ehviewer.core.util.withUIContext
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.parser.Archive
|
||||
import com.hippo.ehviewer.spider.SpiderDen
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.materialkolor.hct.Hct
|
||||
import com.materialkolor.ktx.from
|
||||
import com.materialkolor.ktx.toColor
|
||||
import kotlin.math.abs
|
||||
import com.materialkolor.utils.ColorUtils.lstarFromArgb
|
||||
import splitties.systemservices.downloadManager
|
||||
|
||||
object EhUtils {
|
||||
const val NONE = -1 // Use it for homepage
|
||||
@@ -87,6 +105,9 @@ object EhUtils {
|
||||
val isExHentai: Boolean
|
||||
get() = Settings.gallerySite.value == EhUrl.SITE_EX
|
||||
|
||||
val isMpvAvailable
|
||||
get() = EhCookieStore.getHathPerks()?.contains('q') == true
|
||||
|
||||
fun getCategory(type: String?): Int {
|
||||
for (entry in CATEGORY_STRINGS) {
|
||||
for (str in entry.first) {
|
||||
@@ -102,25 +123,10 @@ object EhUtils {
|
||||
|
||||
fun invCategory(category: Int): Int = category.inv() and ALL_CATEGORY
|
||||
|
||||
private fun differenceDegrees(a: Double, b: Double): Double = 180.0 - abs(abs(a - b) - 180.0)
|
||||
|
||||
private fun sanitizeDegreesDouble(degrees: Double): Double {
|
||||
val deg = degrees % 360.0
|
||||
return if (deg < 0) deg + 360.0 else deg
|
||||
}
|
||||
|
||||
private fun rotationDirection(from: Double, to: Double): Double {
|
||||
val increasingDifference = sanitizeDegreesDouble(to - from)
|
||||
return if (increasingDifference <= 180.0) 1.0 else -1.0
|
||||
}
|
||||
|
||||
val harmonizeWithRole = { primaryContainer: Color, src: Color ->
|
||||
val mergeColor = { primaryContainer: Color, src: Color ->
|
||||
val fromHct = Hct.from(src)
|
||||
val toHct = Hct.from(primaryContainer)
|
||||
val differenceDegrees = differenceDegrees(toHct.hue, fromHct.hue)
|
||||
val rotationDegrees = minOf(differenceDegrees * 0.5, 15.0)
|
||||
val outputHue = sanitizeDegreesDouble(fromHct.hue + rotationDegrees * rotationDirection(fromHct.hue, toHct.hue))
|
||||
Hct.from(outputHue, toHct.chroma, toHct.tone).toColor()
|
||||
Hct.from(fromHct.hue, toHct.chroma, toHct.tone).toColor()
|
||||
}.memoize()
|
||||
|
||||
@Stable
|
||||
@@ -142,15 +148,23 @@ object EhUtils {
|
||||
else -> BG_COLOR_UNKNOWN
|
||||
}.toInt(),
|
||||
)
|
||||
return if (Settings.harmonizeCategoryColor) {
|
||||
return if (Settings.harmonizeCategoryColor.value) {
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||
harmonizeWithRole(primaryContainer, primary)
|
||||
mergeColor(primaryContainer, primary)
|
||||
} else {
|
||||
primary
|
||||
}
|
||||
}
|
||||
|
||||
val categoryTextColor = Color(0xffe6e0e9)
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun getCategoryTextColor(color: Color) = if (isSystemInDarkTheme() == lstarFromArgb(color.toArgb()) > 70) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
|
||||
val favoriteIconColor = Color(0xffff3040)
|
||||
|
||||
fun signOut() {
|
||||
EhCookieStore.removeAllCookies()
|
||||
@@ -160,7 +174,7 @@ object EhUtils {
|
||||
Settings.needSignIn.value = true
|
||||
}
|
||||
|
||||
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle) {
|
||||
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle.value) {
|
||||
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
|
||||
} else {
|
||||
if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title
|
||||
@@ -175,4 +189,35 @@ object EhUtils {
|
||||
// TODO But not sure every '|' means that
|
||||
return title.substringBeforeLast('|').trim().ifEmpty { null }
|
||||
}
|
||||
|
||||
context(ctx: Context)
|
||||
suspend fun downloadArchive(galleryDetail: GalleryDetail, archive: Archive) {
|
||||
val gid = galleryDetail.gid
|
||||
EhEngine.downloadArchive(gid, galleryDetail.token, archive.res, archive.isHath)?.let {
|
||||
val uri = it.toUri()
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
setDataAndType(uri, "application/zip")
|
||||
}
|
||||
val name = "$gid-${getSuitableTitle(galleryDetail)}.zip"
|
||||
try {
|
||||
ctx.startActivity(intent)
|
||||
withUIContext { addTextToClipboard(name, true) }
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
val r = DownloadManager.Request(uri)
|
||||
r.setDestinationInExternalPublicDir(
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
|
||||
)
|
||||
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
downloadManager.enqueue(r)
|
||||
}
|
||||
if (Settings.archiveMetadata.value) {
|
||||
SpiderDen(galleryDetail).apply {
|
||||
initDownloadDir()
|
||||
writeComicInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
interface AbstractDownloadInfo {
|
||||
var state: Int
|
||||
var legacy: Int
|
||||
var time: Long
|
||||
var label: String?
|
||||
var speed: Long
|
||||
var remaining: Long
|
||||
var finished: Int
|
||||
var downloaded: Int
|
||||
var total: Int
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@Entity(tableName = "GALLERIES")
|
||||
@TypeConverters(SimpleTagsConverter::class)
|
||||
data class BaseGalleryInfo(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "GID")
|
||||
override var gid: Long = 0,
|
||||
|
||||
@ColumnInfo(name = "TOKEN")
|
||||
override var token: String = "",
|
||||
|
||||
@ColumnInfo(name = "TITLE")
|
||||
override var title: String? = null,
|
||||
|
||||
@ColumnInfo(name = "TITLE_JPN")
|
||||
override var titleJpn: String? = null,
|
||||
|
||||
@ColumnInfo(name = "THUMB")
|
||||
override var thumbKey: String? = null,
|
||||
|
||||
@ColumnInfo(name = "CATEGORY")
|
||||
override var category: Int = 0,
|
||||
|
||||
@ColumnInfo(name = "POSTED")
|
||||
override var posted: String? = null,
|
||||
|
||||
@ColumnInfo(name = "UPLOADER")
|
||||
override var uploader: String? = null,
|
||||
|
||||
@Ignore
|
||||
override var disowned: Boolean = false,
|
||||
|
||||
@ColumnInfo(name = "RATING")
|
||||
override var rating: Float = 0f,
|
||||
|
||||
@Ignore
|
||||
override var rated: Boolean = false,
|
||||
|
||||
@ColumnInfo(name = "SIMPLE_TAGS")
|
||||
override var simpleTags: List<String>? = null,
|
||||
|
||||
@ColumnInfo(name = "PAGES", defaultValue = "0")
|
||||
override var pages: Int = 0,
|
||||
|
||||
@Ignore
|
||||
override var thumbWidth: Int = 0,
|
||||
|
||||
@Ignore
|
||||
override var thumbHeight: Int = 0,
|
||||
|
||||
@ColumnInfo(name = "SIMPLE_LANGUAGE")
|
||||
override var simpleLanguage: String? = null,
|
||||
|
||||
@ColumnInfo(name = "FAVORITE_SLOT")
|
||||
override var favoriteSlot: Int = NOT_FAVORITED,
|
||||
|
||||
@Ignore
|
||||
override var favoriteName: String? = null,
|
||||
|
||||
@Ignore
|
||||
override var favoriteNote: String? = null,
|
||||
) : GalleryInfo, Parcelable
|
||||
|
||||
class SimpleTagsConverter {
|
||||
@TypeConverter
|
||||
fun fromString(value: String) = value.split(", ")
|
||||
|
||||
@TypeConverter
|
||||
fun toString(value: List<String>) = value.joinToString()
|
||||
}
|
||||
@@ -15,24 +15,27 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.addQueryParameter
|
||||
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class FavListUrlBuilder(
|
||||
val favCat: Int = FAV_CAT_ALL,
|
||||
val keyword: String? = null,
|
||||
var jumpTo: String? = null,
|
||||
var prev: String? = null,
|
||||
var next: String? = null,
|
||||
) : Parcelable {
|
||||
private var jumpTo: String? = null,
|
||||
private var prev: String? = null,
|
||||
private var next: String? = null,
|
||||
) {
|
||||
val isLocal
|
||||
get() = favCat == FAV_CAT_LOCAL
|
||||
|
||||
fun setIndex(index: String?, isNext: Boolean) {
|
||||
next = index.takeIf { isNext }
|
||||
prev = index.takeUnless { isNext }
|
||||
jumpTo = null
|
||||
}
|
||||
|
||||
fun build() = ehUrl(EhUrl.FAV_PATH) {
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.ui.util.fastFlatMap
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANGS
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.ehviewer.core.model.GalleryDetail
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.model.GalleryTagGroup
|
||||
import com.ehviewer.core.model.PowerStatus
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhFilter
|
||||
|
||||
private val LANGUAGES = arrayOf(
|
||||
"English",
|
||||
@@ -37,29 +41,27 @@ private val LANGUAGES = arrayOf(
|
||||
"Dutch",
|
||||
)
|
||||
|
||||
@Parcelize
|
||||
data class GalleryDetail(
|
||||
val galleryInfo: BaseGalleryInfo = BaseGalleryInfo(),
|
||||
var apiUid: Long = -1L,
|
||||
var apiKey: String? = null,
|
||||
var torrentCount: Int = 0,
|
||||
var torrentUrl: String? = null,
|
||||
var archiveUrl: String? = null,
|
||||
var parent: String? = null,
|
||||
var newerVersions: List<BaseGalleryInfo> = emptyList(),
|
||||
var visible: String? = null,
|
||||
var language: String? = null,
|
||||
var size: String? = null,
|
||||
var favoriteCount: Int = 0,
|
||||
var ratingCount: Int = 0,
|
||||
val tagGroups: List<GalleryTagGroup>,
|
||||
var comments: GalleryCommentList,
|
||||
val previewPages: Int,
|
||||
val previewList: List<GalleryPreview>,
|
||||
) : GalleryInfo by galleryInfo, Parcelable {
|
||||
fun fillInfo() {
|
||||
val index = LANGUAGES.indexOf(language)
|
||||
if (index != -1) simpleLanguage = S_LANGS[index]
|
||||
simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags)
|
||||
suspend fun GalleryDetail.fillInfo() {
|
||||
val index = LANGUAGES.indexOf(language)
|
||||
if (index != -1) simpleLanguage = GalleryInfo.S_LANGS[index]
|
||||
simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags).map { (text, power, _) ->
|
||||
if (power == PowerStatus.Weak) "_$text" else text
|
||||
}
|
||||
if (favoriteSlot == GalleryInfo.NOT_FAVORITED && EhDB.containLocalFavorites(gid)) {
|
||||
favoriteSlot = GalleryInfo.LOCAL_FAVORITED
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun GalleryDetail.filterComments() {
|
||||
comments = with(comments) {
|
||||
val scoreThreshold = Settings.commentThreshold.value
|
||||
copy(
|
||||
comments = comments.filter {
|
||||
it.uploader ||
|
||||
it.score > scoreThreshold &&
|
||||
!EhFilter.filterCommenter(it.user.orEmpty()) &&
|
||||
!EhFilter.filterComment(it.comment)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,46 +15,47 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.ehviewer.core.database.model.QuickSearch
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.addQueryParameter
|
||||
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import com.hippo.ehviewer.dao.QuickSearch
|
||||
import com.hippo.ehviewer.ui.main.AdvanceTable
|
||||
import io.ktor.http.Parameters
|
||||
import kotlin.text.toIntOrNull
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class ListUrlBuilder(
|
||||
var mode: Int = MODE_NORMAL,
|
||||
private var prev: String? = null,
|
||||
var next: String? = null,
|
||||
// Reset to null after initial loading
|
||||
var jumpTo: String? = null,
|
||||
private var next: String? = null,
|
||||
private var jumpTo: String? = null,
|
||||
private var range: Int = 0,
|
||||
var category: Int = EhUtils.NONE,
|
||||
private var mKeyword: String? = null,
|
||||
var keyword: String? = null,
|
||||
var hash: String? = null,
|
||||
var language: Int = -1,
|
||||
var advanceSearch: Int = -1,
|
||||
var minRating: Int = -1,
|
||||
var pageFrom: Int = -1,
|
||||
var pageTo: Int = -1,
|
||||
) : Parcelable {
|
||||
) {
|
||||
|
||||
fun setIndex(index: String, isNext: Boolean = true) {
|
||||
next = index.takeIf { isNext }
|
||||
prev = index.takeUnless { isNext }
|
||||
range = 0
|
||||
jumpTo = null
|
||||
}
|
||||
|
||||
fun setJumpTo(to: Int) {
|
||||
jumpTo = to.takeUnless { it == 0 }?.toString()
|
||||
}
|
||||
var page: Int
|
||||
get() = (jumpTo?.toInt() ?: 0) + 1
|
||||
set(value) {
|
||||
jumpTo = (value - 1).takeUnless { it == 0 }?.toString()
|
||||
}
|
||||
|
||||
fun setRange(to: Int) {
|
||||
range = to
|
||||
@@ -63,16 +64,15 @@ data class ListUrlBuilder(
|
||||
jumpTo = null
|
||||
}
|
||||
|
||||
var keyword: String?
|
||||
get() = if (MODE_UPLOADER == mode) "uploader:$mKeyword" else mKeyword
|
||||
set(keyword) {
|
||||
mKeyword = keyword
|
||||
}
|
||||
fun setSeek(date: String) {
|
||||
setIndex("2", true)
|
||||
jumpTo = date
|
||||
}
|
||||
|
||||
constructor(q: QuickSearch) : this(
|
||||
mode = q.mode,
|
||||
category = q.category,
|
||||
mKeyword = q.keyword,
|
||||
keyword = q.keyword,
|
||||
advanceSearch = q.advanceSearch,
|
||||
minRating = q.minRating,
|
||||
pageFrom = q.pageFrom,
|
||||
@@ -84,7 +84,7 @@ data class ListUrlBuilder(
|
||||
name = name,
|
||||
mode = mode,
|
||||
category = category,
|
||||
keyword = mKeyword,
|
||||
keyword = keyword,
|
||||
advanceSearch = advanceSearch,
|
||||
minRating = minRating,
|
||||
pageFrom = pageFrom,
|
||||
@@ -101,7 +101,7 @@ data class ListUrlBuilder(
|
||||
if (q.category != this.category) {
|
||||
return false
|
||||
}
|
||||
if (q.keyword != mKeyword) {
|
||||
if (q.keyword != keyword) {
|
||||
return false
|
||||
}
|
||||
if (q.advanceSearch != advanceSearch) {
|
||||
@@ -123,7 +123,7 @@ data class ListUrlBuilder(
|
||||
jumpTo = params["seek"],
|
||||
range = params["range"]?.toIntOrNull() ?: 0,
|
||||
category = params["f_cats"]?.toIntOrNull()?.let(EhUtils::invCategory) ?: EhUtils.NONE,
|
||||
mKeyword = params["f_search"],
|
||||
keyword = params["f_search"],
|
||||
) {
|
||||
if (params["advsearch"] == "1") {
|
||||
advanceSearch = 0
|
||||
@@ -154,7 +154,7 @@ data class ListUrlBuilder(
|
||||
if (category > 0) {
|
||||
addQueryParameter("f_cats", "${EhUtils.invCategory(category)}")
|
||||
}
|
||||
val query = mKeyword?.let { keyword ->
|
||||
val query = keyword?.let { keyword ->
|
||||
if (language == -1 || "gid:" in keyword || "l:" in keyword || "language:" in keyword) {
|
||||
keyword
|
||||
} else {
|
||||
@@ -206,26 +206,23 @@ data class ListUrlBuilder(
|
||||
}
|
||||
}
|
||||
}.buildString()
|
||||
|
||||
MODE_UPLOADER, MODE_TAG -> {
|
||||
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
|
||||
ehUrl(listOf(path, requireNotNull(mKeyword))) {
|
||||
ehUrl([path, requireNotNull(keyword)]) {
|
||||
addQueryParameterIfNotBlank("prev", prev)
|
||||
addQueryParameterIfNotBlank("next", next)
|
||||
addQueryParameterIfNotBlank("seek", jumpTo)
|
||||
addQueryParameterIfNotBlank("range", range.takeIf { it > 0 }?.toString())
|
||||
}.buildString()
|
||||
}
|
||||
|
||||
MODE_WHATS_HOT -> EhUrl.popularUrl
|
||||
MODE_IMAGE_SEARCH -> ehUrl {
|
||||
addQueryParameter("f_shash", requireNotNull(hash))
|
||||
}.buildString()
|
||||
MODE_TOPLIST -> ehUrl("toplist.php", EhUrl.DOMAIN_E) {
|
||||
addQueryParameter("tl", requireNotNull(mKeyword))
|
||||
addQueryParameter("tl", requireNotNull(keyword))
|
||||
addQueryParameterIfNotBlank("p", jumpTo)
|
||||
}.buildString()
|
||||
|
||||
else -> throw IllegalStateException("Unexpected value: $mode")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import com.ehviewer.core.i18n.R
|
||||
|
||||
class CloudflareBypassException : EhException(R.string.cloudflare_bypass_failed)
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.cbor.CborArray
|
||||
|
||||
@Serializable
|
||||
sealed interface EhError {
|
||||
@CborArray
|
||||
@Serializable
|
||||
@SerialName("NoHits")
|
||||
data object NoHits : EhError
|
||||
|
||||
@CborArray
|
||||
@Serializable
|
||||
@SerialName("NoWatched")
|
||||
data object NoWatched : EhError
|
||||
|
||||
@CborArray
|
||||
@Serializable
|
||||
@SerialName("NeedLogin")
|
||||
data object NeedLogin : EhError
|
||||
|
||||
@CborArray
|
||||
@Serializable
|
||||
@SerialName("NoHathClient")
|
||||
data object NoHathClient : EhError
|
||||
|
||||
@CborArray
|
||||
@Serializable
|
||||
@SerialName("InsufficientFunds")
|
||||
data object InsufficientFunds : EhError
|
||||
|
||||
@CborArray
|
||||
@Serializable
|
||||
@SerialName("GalleryUnavailable")
|
||||
data class GalleryUnavailable(val message: String) : EhError
|
||||
|
||||
@CborArray
|
||||
@Serializable
|
||||
@SerialName("IpBanned")
|
||||
data class IpBanned(val message: String) : EhError
|
||||
|
||||
@CborArray
|
||||
@Serializable
|
||||
@SerialName("Error")
|
||||
data class Error(val message: String) : EhError
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.ehviewer.core.i18n.R
|
||||
|
||||
sealed class FatalException : EhException {
|
||||
constructor(message: String) : super(message)
|
||||
constructor(@StringRes messageId: Int) : super(messageId)
|
||||
}
|
||||
|
||||
class IpBannedException(message: String) : FatalException(message)
|
||||
class InsufficientGpException : FatalException(R.string.insufficient_gp)
|
||||
class QuotaExceededException : FatalException(R.string.error_509)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import com.hippo.ehviewer.R
|
||||
import com.ehviewer.core.i18n.R
|
||||
|
||||
class InsufficientFundsException : EhException(R.string.insufficient_funds)
|
||||
+3
-1
@@ -15,4 +15,6 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
class NoHAtHClientException(detailMessage: String?) : EhException(detailMessage)
|
||||
import com.ehviewer.core.i18n.R
|
||||
|
||||
class NoHathClientException() : EhException(R.string.download_archive_failure_no_hath)
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import com.hippo.ehviewer.R
|
||||
import com.ehviewer.core.i18n.R
|
||||
|
||||
class NoHitsFoundException : EhException(R.string.gallery_list_empty_hit)
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import com.hippo.ehviewer.R
|
||||
import com.ehviewer.core.i18n.R
|
||||
|
||||
class NotLoggedInException : EhException(R.string.sign_in_first)
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
class QuotaExceededException : EhException(R.string.error_509)
|
||||
@@ -15,72 +15,43 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
import com.hippo.ehviewer.client.exception.NoHAtHClientException
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import org.jsoup.Jsoup
|
||||
import arrow.core.Either.Companion.catch
|
||||
import arrow.core.getOrElse
|
||||
import arrow.core.partially3
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object ArchiveParser {
|
||||
private val PATTERN_ARCHIVE_URL =
|
||||
Regex("<strong>(.*)</strong>.*<a href=\"([^\"]*)\">Click Here To Start Downloading</a>")
|
||||
private val PATTERN_CURRENT_FUNDS =
|
||||
Regex("<p>([\\d,]+) GP \\[[^]]*] ([\\d,]+) Credits \\[[^]]*]</p>")
|
||||
private val PATTERN_HATH_ARCHIVE =
|
||||
Regex("<p><a href=\"[^\"]*\" onclick=\"return do_hathdl\\('([0-9]+|org)'\\)\">([^<]+)</a></p>\\s*<p>([\\w. ]+)</p>\\s*<p>([\\w. ]+)</p>")
|
||||
private const val ERROR_NEED_HATH_CLIENT =
|
||||
"You must have a H@H client assigned to your account to use this feature."
|
||||
private const val ERROR_INSUFFICIENT_FUNDS =
|
||||
"You do not have enough funds to download this archive."
|
||||
|
||||
fun parse(body: String, maybeFunds: Funds?): Result {
|
||||
val archiveList = ArrayList<Archive>()
|
||||
Jsoup.parse(body).select("#db>div>div").forEach { element ->
|
||||
if (element.childrenSize() > 0 && !element.attr("style").contains("color:#CCCCCC")) {
|
||||
runCatching {
|
||||
val res = element.selectFirst("form>input")!!.attr("value")
|
||||
val name = element.selectFirst("form>div>input")!!.attr("value")
|
||||
val size = element.selectFirst("p>strong")!!.text()
|
||||
val cost = element.selectFirst("div>strong")!!.text().replace(",", "")
|
||||
Archive(res, name, size, cost, false)
|
||||
}.onSuccess {
|
||||
archiveList.add(it)
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
}
|
||||
fun parse(body: ByteBuffer, maybeFunds: Funds?) = catch {
|
||||
if (maybeFunds != null) {
|
||||
val archives = unmarshalParsingAs<List<Archive>>(body, ::parseArchives)
|
||||
Result(archives, maybeFunds)
|
||||
} else {
|
||||
unmarshalParsingAs<Result>(body, ::parseArchives.partially3(true))
|
||||
}
|
||||
PATTERN_HATH_ARCHIVE.findAll(body).forEach { matchResult ->
|
||||
val (res, name, size, cost) = matchResult.groupValues.slice(1..4)
|
||||
.map { ParserUtils.trim(it) }
|
||||
val item = Archive(res, name, size, cost, true)
|
||||
archiveList.add(item)
|
||||
}
|
||||
val funds = maybeFunds ?: PATTERN_CURRENT_FUNDS.find(body)!!.groupValues.run {
|
||||
val fundsGP = ParserUtils.parseInt(get(1), 0) / 1000
|
||||
val fundsC = ParserUtils.parseInt(get(2), 0)
|
||||
Funds(fundsGP, fundsC)
|
||||
}
|
||||
return Result(archiveList, funds)
|
||||
}.getOrElse {
|
||||
throw ParseException("Can't parse archive list", it)
|
||||
}
|
||||
|
||||
fun parseArchiveUrl(body: String): String? {
|
||||
if (body.contains(ERROR_NEED_HATH_CLIENT)) {
|
||||
throw NoHAtHClientException("No H@H client")
|
||||
} else if (body.contains(ERROR_INSUFFICIENT_FUNDS)) {
|
||||
throw InsufficientFundsException()
|
||||
}
|
||||
return Jsoup.parse(body).selectFirst("#continue>a[href]")
|
||||
?.let { it.attr("href") + "?start=1" }
|
||||
// TODO: Check more errors
|
||||
fun parseArchiveUrl(body: ByteBuffer) = catch {
|
||||
unmarshalParsingAs<String?>(body, ::parseArchiveUrl)
|
||||
}.getOrElse {
|
||||
throw ParseException("Can't parse archive url", it)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Result(val archiveList: List<Archive>, val funds: Funds)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class Archive(
|
||||
val res: String,
|
||||
val name: String,
|
||||
val size: String,
|
||||
val cost: String,
|
||||
val isHAtH: Boolean,
|
||||
val isHath: Boolean,
|
||||
)
|
||||
|
||||
private external fun parseArchives(body: ByteBuffer, size: Int = body.limit(), parseFunds: Boolean = false): Int
|
||||
private external fun parseArchiveUrl(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
@@ -17,8 +17,10 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object EventPaneParser {
|
||||
fun parse(body: String): String? = Jsoup.parse(body).getElementById("eventpane")?.html()
|
||||
private external fun parseEventPane(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
|
||||
fun parse(body: ByteBuffer): String? = unmarshalParsingAs(body, ::parseEventPane)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.ehviewer.core.util.unescapeXml
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.serialization.Serializable
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.exception.NotLoggedInException
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
object ForumsParser {
|
||||
fun parse(body: String): String = Either.catch {
|
||||
val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
|
||||
val userlinks = d.getElementById("userlinks")
|
||||
val child = userlinks!!.child(0).child(0).child(0)
|
||||
child.attr("href")
|
||||
}.getOrElse {
|
||||
throw NotLoggedInException()
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,11 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.util.unescapeXml
|
||||
import com.hippo.ehviewer.client.EhUtils.getCategory
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.getThumbKey
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
@@ -17,429 +17,38 @@ package com.hippo.ehviewer.client.parser
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import arrow.core.unzip
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhFilter
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryComment
|
||||
import com.hippo.ehviewer.client.data.GalleryCommentList
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
|
||||
import com.hippo.ehviewer.client.data.GalleryTagGroup
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.client.data.V1GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.V2GalleryPreview
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.OffensiveException
|
||||
import com.ehviewer.core.model.GalleryCommentList
|
||||
import com.ehviewer.core.model.GalleryDetail
|
||||
import com.ehviewer.core.model.GalleryPreviewList
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.client.exception.PiningException
|
||||
import com.hippo.ehviewer.client.getThumbKey
|
||||
import com.hippo.ehviewer.client.getV2PreviewKey
|
||||
import com.hippo.ehviewer.util.toEpochMillis
|
||||
import com.hippo.ehviewer.util.toFloatOrDefault
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
import com.hippo.ehviewer.util.trimAnd
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.format.MonthNames
|
||||
import kotlinx.datetime.format.char
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.nodes.Node
|
||||
import org.jsoup.select.NodeTraversor
|
||||
import org.jsoup.select.NodeVisitor
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.cbor.CborArray
|
||||
|
||||
object GalleryDetailParser {
|
||||
private val PATTERN_ERROR = Regex("<div class=\"d\">\n<p>([^<]+)</p>")
|
||||
private val PATTERN_DETAIL = Regex(
|
||||
"var gid = (\\d+);.+?var token = \"([a-f0-9]+)\";.+?var apiuid = ([\\-\\d]+);.+?var apikey = \"([a-f0-9]+)\";",
|
||||
RegexOption.DOT_MATCHES_ALL,
|
||||
)
|
||||
private val PATTERN_TORRENT =
|
||||
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download \\((\\d+)\\)</a>")
|
||||
private val PATTERN_ARCHIVE =
|
||||
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Archive Download</a>")
|
||||
private val PATTERN_COVER = Regex("width:(\\d+)px; height:(\\d+)px.+?url\\((.+?)\\)")
|
||||
private val PATTERN_PAGES =
|
||||
Regex("<tr><td[^<>]*>Length:</td><td[^<>]*>([\\d,]+) pages</td></tr>")
|
||||
private val PATTERN_PREVIEW_PAGES =
|
||||
Regex("<td[^>]+><a[^>]+>([\\d,]+)</a></td><td[^>]+>(?:<a[^>]+>)?>(?:</a>)?</td>")
|
||||
private val PATTERN_NEWER_DATE = Regex(", added (.+?)<br />")
|
||||
private val PATTERN_FAVORITE_SLOT =
|
||||
Regex("/fav.png\\); background-position:0px -(\\d+)px")
|
||||
private val EMPTY_GALLERY_COMMENT_LIST = GalleryCommentList(emptyList(), false)
|
||||
private val PreviewRegex =
|
||||
Regex("<a href=\"([^\"]+)\">(?:<div>)?<div title=\"Page (\\d+)(?:[^\"]+\"){2}\\D+(\\d+)\\D+(\\d+)[^(]+\\(([^)]+)\\)(?: -(\\d+))?")
|
||||
|
||||
// dd MMMM yyyy, HH:mm
|
||||
private val WEB_COMMENT_DATE_FORMAT = LocalDateTime.Format {
|
||||
dayOfMonth()
|
||||
char(' ')
|
||||
monthName(MonthNames.ENGLISH_FULL)
|
||||
char(' ')
|
||||
year()
|
||||
chars(", ")
|
||||
hour()
|
||||
char(':')
|
||||
minute()
|
||||
}
|
||||
private const val OFFENSIVE_STRING =
|
||||
"<p>(And if you choose to ignore this warning, you lose all rights to complain about it in the future.)</p>"
|
||||
private const val PINING_STRING = "<p>This gallery is pining for the fjords.</p>"
|
||||
|
||||
suspend fun parse(body: String): GalleryDetail {
|
||||
if (body.contains(OFFENSIVE_STRING)) {
|
||||
throw OffensiveException()
|
||||
}
|
||||
if (body.contains(PINING_STRING)) {
|
||||
throw PiningException()
|
||||
}
|
||||
|
||||
// Error info
|
||||
PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) }
|
||||
val document = Jsoup.parse(body)
|
||||
val galleryDetail = GalleryDetail(
|
||||
tagGroups = parseTagGroups(document),
|
||||
comments = parseComments(document),
|
||||
previewPages = parsePreviewPages(body),
|
||||
previewList = parsePreviewList(body).first,
|
||||
)
|
||||
parseDetail(galleryDetail, document, body)
|
||||
|
||||
// Fill info for database
|
||||
galleryDetail.fillInfo()
|
||||
return galleryDetail
|
||||
}
|
||||
|
||||
private suspend fun parseDetail(gd: GalleryDetail, d: Document, body: String) {
|
||||
PATTERN_DETAIL.find(body)?.apply {
|
||||
gd.gid = groupValues[1].toLongOrNull() ?: -1L
|
||||
gd.token = groupValues[2]
|
||||
gd.apiUid = groupValues[3].toLongOrNull() ?: -1L
|
||||
gd.apiKey = groupValues[4]
|
||||
} ?: throw ParseException("Can't parse gallery detail")
|
||||
if (gd.gid == -1L) {
|
||||
throw ParseException("Can't parse gallery detail")
|
||||
}
|
||||
PATTERN_TORRENT.find(body)?.run {
|
||||
gd.torrentUrl = groupValues[1].trim().unescapeXml()
|
||||
gd.torrentCount = groupValues[2].toIntOrNull() ?: 0
|
||||
}
|
||||
PATTERN_ARCHIVE.find(body)?.run {
|
||||
gd.archiveUrl = groupValues[1].trim().unescapeXml()
|
||||
}
|
||||
Either.catch {
|
||||
val gm = d.getElementsByClass("gm")[0]
|
||||
// Thumb url
|
||||
gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let {
|
||||
gd.thumbKey = getThumbKey(PATTERN_COVER.find(it)!!.groupValues[3])
|
||||
}
|
||||
|
||||
gd.title = gm.getElementById("gn")?.text()?.trim()
|
||||
gd.titleJpn = gm.getElementById("gj")?.text()?.trim()
|
||||
|
||||
// Category
|
||||
gd.category = Either.catch {
|
||||
val gdc = gm.getElementById("gdc")!!
|
||||
var ce = gdc.getElementsByClass("cn").first()
|
||||
if (ce == null) {
|
||||
ce = gdc.getElementsByClass("cs").first()
|
||||
}
|
||||
EhUtils.getCategory(ce!!.text())
|
||||
}.getOrElse {
|
||||
EhUtils.UNKNOWN
|
||||
}
|
||||
|
||||
// Uploader
|
||||
val gdn = gm.getElementById("gdn")
|
||||
if (null != gdn) {
|
||||
gd.disowned = gdn.attr("style").contains("opacity:0.5")
|
||||
gd.uploader = gdn.text().trim()
|
||||
} else {
|
||||
gd.uploader = ""
|
||||
}
|
||||
val gdd = gm.getElementById("gdd")
|
||||
gd.posted = ""
|
||||
gd.parent = ""
|
||||
gd.visible = ""
|
||||
gd.size = ""
|
||||
gd.pages = 0
|
||||
gd.favoriteCount = 0
|
||||
val es = gdd!!.child(0).child(0).children()
|
||||
es.forEach {
|
||||
parseDetailInfo(gd, it)
|
||||
}
|
||||
|
||||
// Rating count
|
||||
val ratingCount = gm.getElementById("rating_count")
|
||||
if (null != ratingCount) {
|
||||
gd.ratingCount = ratingCount.text().trim().toIntOrDefault(0)
|
||||
} else {
|
||||
gd.ratingCount = 0
|
||||
}
|
||||
|
||||
// Rating
|
||||
val ratingLabel = gm.getElementById("rating_label")
|
||||
if (null != ratingLabel) {
|
||||
val ratingStr = ratingLabel.text().trim()
|
||||
if ("Not Yet Rated" == ratingStr) {
|
||||
gd.rating = -1.0f
|
||||
} else {
|
||||
val index = ratingStr.indexOf(' ')
|
||||
if (index == -1 || index >= ratingStr.length) {
|
||||
gd.rating = 0f
|
||||
} else {
|
||||
gd.rating = ratingStr.substring(index + 1).toFloatOrDefault(0f)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
gd.rating = -1.0f
|
||||
}
|
||||
|
||||
// isFavorited
|
||||
val gdf = gm.getElementById("gdf")
|
||||
if (gdf != null) {
|
||||
val favoriteName = gdf.text().trim()
|
||||
if (favoriteName != "Add to Favorites") {
|
||||
gd.favoriteName = gdf.text().trim()
|
||||
PATTERN_FAVORITE_SLOT.find(body)?.run {
|
||||
gd.favoriteSlot = ((groupValues[1].toIntOrNull() ?: 2) - 2) / 19
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gd.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(gd.gid)) {
|
||||
gd.favoriteSlot = LOCAL_FAVORITED
|
||||
}
|
||||
}.getOrElse {
|
||||
throw ParseException("Can't parse gallery detail")
|
||||
}
|
||||
|
||||
// newer version
|
||||
d.getElementById("gnd")?.run {
|
||||
val dates = PATTERN_NEWER_DATE.findAll(body).map { it.groupValues[1] }.toList()
|
||||
gd.newerVersions = select("a").mapIndexedNotNull { index, element ->
|
||||
val result = GalleryDetailUrlParser.parse(element.attr("href"))
|
||||
result?.run {
|
||||
BaseGalleryInfo(gid, token, element.text().trim(), posted = dates[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDetailInfo(gd: GalleryDetail, e: Element) {
|
||||
val es = e.children()
|
||||
if (es.size < 2) {
|
||||
return
|
||||
}
|
||||
val key = es[0].text().trim()
|
||||
val value = es[1].ownText().trim()
|
||||
if (key.startsWith("Posted")) {
|
||||
gd.posted = value
|
||||
} else if (key.startsWith("Parent")) {
|
||||
val a = es[1].children().first()
|
||||
if (a != null) {
|
||||
gd.parent = a.attr("href")
|
||||
}
|
||||
} else if (key.startsWith("Visible")) {
|
||||
gd.visible = value
|
||||
} else if (key.startsWith("Language")) {
|
||||
gd.language = value
|
||||
} else if (key.startsWith("File Size")) {
|
||||
gd.size = value
|
||||
} else if (key.startsWith("Length")) {
|
||||
val index = value.indexOf(' ')
|
||||
if (index >= 0) {
|
||||
gd.pages = value.substring(0, index).toIntOrDefault(1)
|
||||
} else {
|
||||
gd.pages = 1
|
||||
}
|
||||
} else if (key.startsWith("Favorited")) {
|
||||
when (value) {
|
||||
"Never" -> gd.favoriteCount = 0
|
||||
"Once" -> gd.favoriteCount = 1
|
||||
else -> {
|
||||
val index = value.indexOf(' ')
|
||||
if (index == -1) {
|
||||
gd.favoriteCount = 0
|
||||
} else {
|
||||
gd.favoriteCount = value.substring(0, index).toIntOrDefault(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSingleTagGroup(element: Element): GalleryTagGroup? = Either.catch {
|
||||
val nameSpace = with(element.child(0).text()) {
|
||||
// Remove last ':'
|
||||
TagNamespace.from(substring(0, length - 1))
|
||||
}
|
||||
checkNotNull(nameSpace) { "$nameSpace is not a valid tag namespace!" }
|
||||
val tags = element.child(1).children().map { e ->
|
||||
val text = e.text()
|
||||
// Sometimes parody tag is followed with '|' and english translate, just remove them
|
||||
val index = text.indexOf('|')
|
||||
val tag = if (index >= 0) text.substring(0, index).trim() else text
|
||||
// weak tag
|
||||
if (e.className() == "gtw") "_$tag" else tag
|
||||
}
|
||||
check(tags.isNotEmpty()) { "TagGroup is empty!" }
|
||||
GalleryTagGroup(nameSpace, tags)
|
||||
fun parse(body: ByteBuffer) = Either.catch {
|
||||
unmarshalParsingAs<Result>(body, ::nativeParse)
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
null
|
||||
throw ParseException("Failed to parse gallery detail", it)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tag groups with html parser
|
||||
*/
|
||||
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = Either.catch {
|
||||
val taglist = document.getElementById("taglist")!!
|
||||
taglist.child(0).child(0).children().mapNotNull(::parseSingleTagGroup)
|
||||
fun parseComments(body: ByteBuffer) = Either.catch {
|
||||
unmarshalParsingAs<GalleryCommentList>(body, ::nativeParseComments)
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
emptyList()
|
||||
throw ParseException("Failed to parse comments", it)
|
||||
}
|
||||
|
||||
private suspend fun parseComment(element: Element): GalleryComment? = Either.catch {
|
||||
// Id
|
||||
val a = element.previousElementSibling()
|
||||
val name = a!!.attr("name")
|
||||
val id = name trimAnd { substring(1).toInt().toLong() }
|
||||
// Editable, vote up and vote down
|
||||
val c4 = element.getElementsByClass("c4").first()
|
||||
var voteUpAble = false
|
||||
var voteUpEd = false
|
||||
var voteDownAble = false
|
||||
var voteDownEd = false
|
||||
var editable = false
|
||||
var uploader = false
|
||||
if (null != c4) {
|
||||
if ("Uploader Comment" == c4.text()) {
|
||||
uploader = true
|
||||
}
|
||||
for (e in c4.children()) {
|
||||
when (e.text()) {
|
||||
"Vote+" -> {
|
||||
voteUpAble = true
|
||||
voteUpEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Vote-" -> {
|
||||
voteDownAble = true
|
||||
voteDownEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Edit" -> editable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Vote state
|
||||
val c7 = element.getElementsByClass("c7").first()
|
||||
val voteState = c7?.text()?.trim()
|
||||
// Score
|
||||
val c5 = element.getElementsByClass("c5").first()
|
||||
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
|
||||
// time
|
||||
val c3 = element.getElementsByClass("c3").first()
|
||||
val temp = c3!!.ownText()
|
||||
val (timeStr, user) = if (temp.endsWith(':')) {
|
||||
temp.substring("Posted on ".length, temp.length - " by:".length) to c3.child(0).text()
|
||||
} else {
|
||||
temp.substring("Posted on ".length) to null
|
||||
}
|
||||
val time = WEB_COMMENT_DATE_FORMAT.parse(timeStr).toEpochMillis()
|
||||
// comment
|
||||
val c6 = element.getElementsByClass("c6").first()
|
||||
// fix underline support
|
||||
for (e in c6!!.children()) {
|
||||
if ("span" == e.tagName() && "text-decoration:underline;" == e.attr("style")) {
|
||||
e.tagName("u")
|
||||
}
|
||||
}
|
||||
val commentHtml = c6.html()
|
||||
// filter comment
|
||||
if (!uploader) {
|
||||
val sEhFilter = EhFilter
|
||||
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// last edited
|
||||
val c8 = element.getElementsByClass("c8").first()
|
||||
val lastEdited = c8?.children()?.first()?.run { WEB_COMMENT_DATE_FORMAT.parse(text()).toEpochMillis() } ?: 0
|
||||
GalleryComment(
|
||||
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
|
||||
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
|
||||
)
|
||||
fun parsePreviews(body: ByteBuffer) = Either.catch {
|
||||
unmarshalParsingAs<GalleryPreviewList>(body, ::nativeParsePreviews)
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
null
|
||||
throw ParseException("Failed to parse previews", it)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse comments with html parser
|
||||
*/
|
||||
suspend fun parseComments(document: Document): GalleryCommentList = Either.catch {
|
||||
// Disable pretty print to get comments in raw html
|
||||
document.outputSettings().prettyPrint(false)
|
||||
val cdiv = document.getElementById("cdiv")!!
|
||||
val c1s = cdiv.getElementsByClass("c1")
|
||||
val list = c1s.mapNotNull { parseComment(it) }
|
||||
val chd = cdiv.getElementById("chd")
|
||||
var hasMore = false
|
||||
NodeTraversor.traverse(
|
||||
object : NodeVisitor {
|
||||
override fun head(node: Node, depth: Int) {
|
||||
if (node is Element && node.text() == "click to show all") {
|
||||
hasMore = true
|
||||
}
|
||||
}
|
||||
@Serializable
|
||||
@CborArray
|
||||
class Result(val detail: GalleryDetail, val event: String?)
|
||||
|
||||
override fun tail(node: Node, depth: Int) {}
|
||||
},
|
||||
chd!!,
|
||||
)
|
||||
GalleryCommentList(list, hasMore)
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
EMPTY_GALLERY_COMMENT_LIST
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse preview pages with regular expressions
|
||||
*/
|
||||
fun parsePreviewPages(body: String): Int = PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: throw ParseException("Parse preview page count error")
|
||||
|
||||
/**
|
||||
* Parse pages with regular expressions
|
||||
*/
|
||||
fun parsePages(body: String): Int = PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: throw ParseException("Parse pages error")
|
||||
|
||||
fun parsePreviewList(body: String) = PreviewRegex.findAll(body).unzip {
|
||||
val pageUrl = it.groupValues[1]
|
||||
val position = it.groupValues[2].toInt() - 1
|
||||
val url = it.groupValues[5]
|
||||
val offset = it.groupValues[6]
|
||||
if (offset.isEmpty()) {
|
||||
val imageKey = getThumbKey(url)
|
||||
V1GalleryPreview(imageKey, position)
|
||||
} else {
|
||||
val imageKey = getV2PreviewKey(url)
|
||||
val width = it.groupValues[3].toInt()
|
||||
val height = it.groupValues[4].toInt()
|
||||
V2GalleryPreview(url, imageKey, position, offset.toInt(), width, height)
|
||||
} to pageUrl
|
||||
}.run {
|
||||
first.toList().apply {
|
||||
if (isEmpty()) throw ParseException("Parse preview list error")
|
||||
} to second.toList()
|
||||
}
|
||||
private external fun nativeParse(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
private external fun nativeParseComments(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
private external fun nativeParsePreviews(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.ehviewer.core.model.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -13,14 +13,14 @@ object GalleryListParser {
|
||||
throw ParseException("Can't parse gallery list", it)
|
||||
}
|
||||
|
||||
val emptyResult = GalleryListResult(null, null, arrayListOf())
|
||||
val emptyResult = GalleryListResult(null, null, [])
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GalleryListResult(
|
||||
val prev: String?,
|
||||
val next: String?,
|
||||
val galleryInfoList: ArrayList<BaseGalleryInfo>,
|
||||
val galleryInfoList: MutableList<BaseGalleryInfo>,
|
||||
)
|
||||
|
||||
private external fun parseGalleryInfoList(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
@@ -26,7 +26,7 @@ object GalleryListUrlParser {
|
||||
fun parse(urlStr: String) = Either.catch {
|
||||
val url = Url(urlStr)
|
||||
if (url.host != EhUrl.DOMAIN_E && url.host != EhUrl.DOMAIN_EX) {
|
||||
return null
|
||||
return@catch null
|
||||
}
|
||||
val segments = url.segments
|
||||
if (segments.isEmpty()) {
|
||||
@@ -42,20 +42,18 @@ object GalleryListUrlParser {
|
||||
mode = if (head == "uploader") ListUrlBuilder.MODE_UPLOADER else ListUrlBuilder.MODE_TAG
|
||||
keyword = segments[1].decodeURLQueryComponent(plusIsSpace = true)
|
||||
}
|
||||
|
||||
"toplist.php" -> {
|
||||
val tl = url.parameters["tl"]
|
||||
if (tl != null && tl in arrayOf("11", "12", "13", "15")) {
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_TOPLIST,
|
||||
jumpTo = url.parameters["p"],
|
||||
mKeyword = tl,
|
||||
keyword = tl,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val category = EhUtils.getCategory(head)
|
||||
if (category != EhUtils.UNKNOWN) {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Tarsin Norbin
|
||||
*
|
||||
* This file is part of EhViewer
|
||||
*
|
||||
* EhViewer is free software: you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* EhViewer is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with EhViewer.
|
||||
* If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
object GalleryNotAvailableParser {
|
||||
fun parse(body: String): String? = Either.catch {
|
||||
val e = Jsoup.parse(body).getElementsByClass("d").first()
|
||||
e!!.child(0).html().replace("<br>", "\n")
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.ehviewer.core.util.unescapeXml
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
|
||||
object GalleryPageParser {
|
||||
private val PATTERN_IMAGE_URL = Regex("<img[^>]*src=\"([^\"]+)\" style")
|
||||
|
||||
@@ -36,7 +36,7 @@ object GalleryTokenApiParser {
|
||||
fun parse(body: String): String = Either.catch {
|
||||
body.parseAs<Result>().tokenList[0].token
|
||||
}.getOrElse {
|
||||
throw EhException(body.parseAs<Error>().error)
|
||||
throw EhException(body.parseAs<VoteTagResult>().error)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object HomeParser {
|
||||
@@ -27,23 +25,21 @@ object HomeParser {
|
||||
?: throw ParseException("Parse torrent key error")
|
||||
|
||||
fun parseFunds(body: String): Funds {
|
||||
FundsRegex.find(body)?.groupValues?.run {
|
||||
val fundsC = ParserUtils.parseInt(get(1), 0)
|
||||
val fundsGP = ParserUtils.parseInt(get(2), 0)
|
||||
FundsRegex.find(body)?.run {
|
||||
val fundsC = ParserUtils.parseInt(groupValues[1], 0)
|
||||
val fundsGP = ParserUtils.parseInt(groupValues[2], 0)
|
||||
return Funds(fundsGP, fundsC)
|
||||
}
|
||||
throw ParseException("Parse funds error")
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Result(val limits: Limits, val funds: Funds, private val id: Int = Random.nextInt()) : Parcelable
|
||||
data class Result(val limits: Limits, val funds: Funds, private val id: Int = Random.nextInt())
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Funds(val gp: Int, val credit: Int) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
|
||||
data class Funds(val gp: Int, val credit: Int)
|
||||
|
||||
@Serializable
|
||||
data class Limits(val current: Int, val maximum: Int, val resetCost: Int)
|
||||
|
||||
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Int
|
||||
@@ -15,11 +15,9 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.util.toEpochMillis
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
import com.hippo.ehviewer.util.toLocalDateTime
|
||||
import com.hippo.ehviewer.util.toLongOrDefault
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import com.ehviewer.core.util.toEpochMillis
|
||||
import com.ehviewer.core.util.toIntOrDefault
|
||||
import com.ehviewer.core.util.toLocalDateTime
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.format.char
|
||||
|
||||
@@ -30,7 +28,7 @@ object ParserUtils {
|
||||
char('-')
|
||||
monthNumber()
|
||||
char('-')
|
||||
dayOfMonth()
|
||||
day()
|
||||
char(' ')
|
||||
hour()
|
||||
char(':')
|
||||
@@ -41,9 +39,5 @@ object ParserUtils {
|
||||
|
||||
fun parseDate(str: String): Long = formatter.parse(str).toEpochMillis()
|
||||
|
||||
fun trim(str: String?): String = str?.unescapeXml()?.trim() ?: ""
|
||||
|
||||
fun parseInt(str: String?, defValue: Int): Int = trim(str).replace(",", "").toIntOrDefault(defValue)
|
||||
|
||||
fun parseLong(str: String?, defValue: Long): Long = trim(str).replace(",", "").toLongOrDefault(defValue)
|
||||
fun parseInt(str: String, defValue: Int): Int = str.replace(",", "").toIntOrDefault(defValue)
|
||||
}
|
||||
@@ -15,36 +15,28 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.Either.Companion.catch
|
||||
import arrow.core.getOrElse
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import org.jsoup.Jsoup
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object ProfileParser {
|
||||
fun parse(body: String): Result = Either.catch {
|
||||
val d = Jsoup.parse(body)
|
||||
val profilename = d.getElementById("profilename")
|
||||
val displayName = profilename!!.child(0).text()
|
||||
val avatar = Either.catch {
|
||||
val avatar =
|
||||
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
|
||||
if (avatar.isEmpty()) {
|
||||
null
|
||||
} else if (!avatar.startsWith("http")) {
|
||||
EhUrl.URL_FORUMS + avatar
|
||||
} else {
|
||||
avatar
|
||||
}
|
||||
}.getOrElse {
|
||||
logcat { "No avatar" }
|
||||
null
|
||||
}
|
||||
Result(displayName, avatar)
|
||||
fun parse(body: ByteBuffer) = catch {
|
||||
unmarshalParsingAs<Result>(body, ::parseProfile)
|
||||
}.getOrElse {
|
||||
throw ParseException("Parse forums error")
|
||||
throw ParseException("Failed to parse profile", it)
|
||||
}
|
||||
|
||||
class Result(val displayName: String?, val avatar: String?)
|
||||
fun parseProfileUrl(body: ByteBuffer) = catch {
|
||||
unmarshalParsingAs<String>(body, ::parseProfileUrl)
|
||||
}.getOrElse {
|
||||
throw ParseException("Failed to parse profile url", it)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Result(val displayName: String, val avatar: String?)
|
||||
|
||||
private external fun parseProfile(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
private external fun parseProfileUrl(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
}
|
||||
@@ -17,9 +17,18 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
import arrow.core.Either.Companion.catch
|
||||
import arrow.core.getOrElse
|
||||
import com.ehviewer.core.model.GalleryTagGroup
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object VoteTagParser {
|
||||
// {"error":"The tag \"neko\" is not allowed. Use character:neko or artist:neko"}
|
||||
fun parse(body: String): String? = runCatching { body.parseAs<Error>().error }.getOrNull()
|
||||
fun parse(body: ByteBuffer) = catch {
|
||||
unmarshalParsingAs<List<GalleryTagGroup>>(body, ::nativeParse)
|
||||
}.getOrElse {
|
||||
throw ParseException("Failed to parse vote tag result", it)
|
||||
}
|
||||
|
||||
private external fun nativeParse(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
}
|
||||
+4
-1
@@ -3,4 +3,7 @@ package com.hippo.ehviewer.client.parser
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Error(val error: String)
|
||||
data class VoteTagResult(
|
||||
val error: String?,
|
||||
val tagpane: String?,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.ByteBufferMetadata
|
||||
import coil3.decode.ContentMetadata
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.gif.isAnimatedWebP
|
||||
import coil3.request.Options
|
||||
import java.io.FileInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import okio.FileSystem
|
||||
|
||||
class AnimatedWebPDecoder(private val source: ByteBuffer) : Decoder {
|
||||
override suspend fun decode() = DecodeResult(AnimatedWebPDrawable(source).asImage(), false)
|
||||
|
||||
object Factory : Decoder.Factory {
|
||||
override fun create(
|
||||
result: SourceFetchResult,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
) = if (DecodeUtils.isAnimatedWebP(result.source.source())) {
|
||||
result.source.toByteBufferOrNull()?.let { AnimatedWebPDecoder(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ImageSource.toByteBufferOrNull(): ByteBuffer? {
|
||||
if (fileSystem === FileSystem.SYSTEM) {
|
||||
val file = fileOrNull()
|
||||
if (file != null) {
|
||||
return file.toFile().inputStream().mapReadOnly()
|
||||
}
|
||||
}
|
||||
return when (val metadata = metadata) {
|
||||
is ContentMetadata -> metadata.assetFileDescriptor.createInputStream().mapReadOnly()
|
||||
is ByteBufferMetadata -> metadata.byteBuffer
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun FileInputStream.mapReadOnly(): ByteBuffer = channel.use { it.map(FileChannel.MapMode.READ_ONLY, 0, it.size()) }
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.SystemClock
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.ehviewer.core.util.logcat
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
// Hold a reference to the buffer as it's used by the decoder
|
||||
@Suppress("CanBeParameter")
|
||||
class AnimatedWebPDrawable(private val source: ByteBuffer) : Drawable(), Animatable {
|
||||
private val decodeScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
private val decoder = nativeCreateDecoder(source)
|
||||
private val width: Int
|
||||
private val height: Int
|
||||
private val loopCount: Int
|
||||
|
||||
private var decodeJob: Job? = null
|
||||
private var loopsCompleted = 0
|
||||
private var timeToShowNextFrame = 0L
|
||||
private var currentFrame: Frame
|
||||
private var nextFrame: Frame
|
||||
|
||||
init {
|
||||
check(decoder != 0L) { "Failed to create decoder" }
|
||||
val packed = nativeGetImageInfo(decoder)
|
||||
width = (packed shr 40).toInt()
|
||||
height = (packed shr 16 and 0xFFFFFF).toInt()
|
||||
loopCount = (packed and 0xFFFF).toInt()
|
||||
val bitmap = createBitmap(width, height)
|
||||
val timestamp = nativeDecodeNextFrame(decoder, false, bitmap)
|
||||
check(timestamp != 0) {
|
||||
nativeDestroyDecoder(decoder)
|
||||
"Failed to decode first frame"
|
||||
}
|
||||
currentFrame = Frame(bitmap, timestamp)
|
||||
nextFrame = Frame(createBitmap(width, height), 0)
|
||||
}
|
||||
|
||||
override fun getIntrinsicWidth() = width
|
||||
|
||||
override fun getIntrinsicHeight() = height
|
||||
|
||||
private val runnable = Runnable {
|
||||
timeToShowNextFrame = SystemClock.uptimeMillis() + nextFrame.timestamp - currentFrame.timestamp
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
private fun decodeNextFrame(reset: Boolean) = decodeScope.launch {
|
||||
val timestamp = nativeDecodeNextFrame(decoder, reset, nextFrame.bitmap)
|
||||
ensureActive()
|
||||
check(timestamp != 0) {
|
||||
decodeJob = null
|
||||
"Failed to decode next frame"
|
||||
}
|
||||
if (timestamp <= currentFrame.timestamp) {
|
||||
if (reset) loopsCompleted = 0 else loopsCompleted++
|
||||
currentFrame.timestamp = 0
|
||||
}
|
||||
nextFrame.timestamp = timestamp
|
||||
nextFrame.bitmap.prepareToDraw()
|
||||
}.apply {
|
||||
invokeOnCompletion { cause ->
|
||||
when (cause) {
|
||||
null -> if (reset) {
|
||||
runnable.run()
|
||||
} else {
|
||||
scheduleSelf(runnable, timeToShowNextFrame)
|
||||
}
|
||||
!is CancellationException -> logcat(cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
if (decodeJob?.isCompleted == true && isVisible) {
|
||||
decodeJob = if (loopCount == 0 || loopsCompleted < loopCount) {
|
||||
currentFrame = nextFrame.also { nextFrame = currentFrame }
|
||||
decodeNextFrame(false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
canvas.drawBitmap(currentFrame.bitmap, null, bounds, paint)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
if (alpha != paint.alpha) {
|
||||
paint.alpha = alpha
|
||||
invalidateSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAlpha(): Int = paint.alpha
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
if (colorFilter != paint.colorFilter) {
|
||||
paint.colorFilter = colorFilter
|
||||
invalidateSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getColorFilter(): ColorFilter? = paint.colorFilter
|
||||
|
||||
override fun isRunning() = decodeJob != null
|
||||
|
||||
override fun start() {
|
||||
if (decodeJob == null) {
|
||||
decodeJob = decodeNextFrame(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
decodeJob?.cancel()
|
||||
decodeJob = null
|
||||
unscheduleSelf(runnable)
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
runBlocking {
|
||||
decodeScope.coroutineContext.job.cancelAndJoin()
|
||||
}
|
||||
nativeDestroyDecoder(decoder)
|
||||
}
|
||||
}
|
||||
|
||||
private class Frame(val bitmap: Bitmap, var timestamp: Int)
|
||||
|
||||
private external fun nativeCreateDecoder(source: ByteBuffer): Long
|
||||
private external fun nativeGetImageInfo(decoder: Long): Long
|
||||
private external fun nativeDecodeNextFrame(decoder: Long, reset: Boolean, bitmap: Bitmap): Int
|
||||
private external fun nativeDestroyDecoder(decoder: Long)
|
||||
@@ -12,14 +12,11 @@ import coil3.asImage
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.ehviewer.core.util.isAtLeastQ
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.hippo.ehviewer.image.copyBitmapToAHB
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private const val FORMAT = HardwareBuffer.RGBA_8888
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private const val USAGE = HardwareBuffer.USAGE_CPU_WRITE_RARELY or HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
|
||||
|
||||
@@ -42,8 +39,14 @@ object CropBorderInterceptor : Interceptor {
|
||||
val meetHardwareThreshold = maxOf(w, h) <= chain.request.hardwareThreshold
|
||||
val bitmap = when {
|
||||
isAtLeastQ && meetHardwareThreshold -> runSuspendCatching {
|
||||
val format = when (val config = src.config) {
|
||||
Bitmap.Config.ARGB_8888 -> HardwareBuffer.RGBA_8888
|
||||
Bitmap.Config.RGB_565 -> HardwareBuffer.RGB_565
|
||||
Bitmap.Config.RGBA_F16 -> HardwareBuffer.RGBA_FP16
|
||||
else -> error("Unsupported bitmap config: $config")
|
||||
}
|
||||
resourceScope {
|
||||
val buffer = autoCloseable { HardwareBuffer.create(w, h, FORMAT, 1, USAGE) }
|
||||
val buffer = autoCloseable { HardwareBuffer.create(w, h, format, 1, USAGE) }
|
||||
copyBitmapToAHB(src, buffer, x, y)
|
||||
Bitmap.wrapHardwareBuffer(buffer, src.colorSpace)
|
||||
}
|
||||
|
||||
@@ -6,42 +6,57 @@ import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.ehviewer.EhApplication.Companion.imageCache
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.isFile
|
||||
import com.hippo.files.toUri
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
import com.ehviewer.core.database.model.DownloadInfo
|
||||
import com.ehviewer.core.files.delete
|
||||
import com.ehviewer.core.files.isDirectory
|
||||
import com.ehviewer.core.files.isFile
|
||||
import com.ehviewer.core.files.sendTo
|
||||
import com.ehviewer.core.files.toUri
|
||||
import com.hippo.ehviewer.EhApplication.Companion.thumbCache
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.client.getThumbKey
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
|
||||
private val emptyPath = "".toPath()
|
||||
private val downloadLocationKey = Extras.Key(default = emptyPath)
|
||||
private val downloadInfoKey = Extras.Key<DownloadInfo?>(default = null)
|
||||
|
||||
fun ImageRequest.Builder.downloadLocation(path: Path) = apply {
|
||||
extras[downloadLocationKey] = path
|
||||
fun ImageRequest.Builder.downloadInfo(info: DownloadInfo) = apply {
|
||||
extras[downloadInfoKey] = info
|
||||
}
|
||||
|
||||
val ImageRequest.downloadLocation: Path
|
||||
get() = getExtra(downloadLocationKey)
|
||||
val ImageRequest.downloadInfo: DownloadInfo?
|
||||
get() = getExtra(downloadInfoKey)
|
||||
|
||||
object DownloadThumbInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val thumb = chain.request.downloadLocation
|
||||
if (thumb != emptyPath) {
|
||||
val info = chain.request.downloadInfo
|
||||
if (info != null && !info.dirname.isNullOrBlank()) {
|
||||
val thumbKey = getThumbKey(chain.request.data as String)
|
||||
if (info.thumbKey != thumbKey) {
|
||||
info.thumbKey = thumbKey
|
||||
EhDB.putGalleryInfo(info.galleryInfo)
|
||||
}
|
||||
val dir = downloadLocation / info.dirname!!
|
||||
val format = thumbKey.substringAfterLast('.', "")
|
||||
check(format.isNotBlank())
|
||||
val thumb = dir / "thumb.$format"
|
||||
val v1Thumb = dir / "thumb.jpg"
|
||||
if (thumb.isFile) {
|
||||
val new = chain.request.newBuilder().data(thumb.toUri()).build()
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult) return result
|
||||
if (result is SuccessResult) {
|
||||
if (thumb != v1Thumb) v1Thumb.delete()
|
||||
return result
|
||||
}
|
||||
}
|
||||
val new = chain.request.newBuilder().build()
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult && thumb.parent?.isDirectory == true) {
|
||||
val result = chain.proceed()
|
||||
if (result is SuccessResult && dir.isDirectory) {
|
||||
// Accessing the recreated file immediately after deleting it throws
|
||||
// FileNotFoundException, so we just overwrite the existing file.
|
||||
val key = requireNotNull(chain.request.memoryCacheKey)
|
||||
imageCache.read(key) {
|
||||
thumbCache.read(key) {
|
||||
data sendTo thumb
|
||||
}
|
||||
if (thumb != v1Thumb) v1Thumb.delete()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,36 +1,61 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import coil3.decode.BlackholeDecoder
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.V2GalleryPreview
|
||||
import coil3.size.SizeResolver
|
||||
import com.ehviewer.core.database.model.DownloadInfo
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.model.GalleryPreview
|
||||
import com.ehviewer.core.model.V2GalleryPreview
|
||||
import com.hippo.ehviewer.client.getThumbKey
|
||||
import com.hippo.ehviewer.client.getV2PreviewKey
|
||||
import com.hippo.ehviewer.client.thumbUrl
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.ktbuilder.execute
|
||||
|
||||
// Load in original size so the memory cache can be reused for preload requests
|
||||
fun ImageRequest.Builder.ehUrl(info: GalleryInfo) = apply {
|
||||
val key = info.thumbKey!!
|
||||
data(info.thumbUrl)
|
||||
val format = info.thumbUrl.substringAfterLast('.', "")
|
||||
check(format.isNotBlank())
|
||||
if (info is DownloadInfo && !info.dirname.isNullOrBlank()) {
|
||||
downloadLocation(downloadLocation / info.dirname / "thumb.$format")
|
||||
size(SizeResolver.ORIGINAL)
|
||||
val downloadInfo = (info as? DownloadInfo) ?: DownloadManager.getDownloadInfo(info.gid)
|
||||
if (downloadInfo != null) {
|
||||
downloadInfo(downloadInfo)
|
||||
}
|
||||
memoryCacheKey(key)
|
||||
diskCacheKey(key)
|
||||
}
|
||||
|
||||
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = with(preview) {
|
||||
data(url)
|
||||
if (preview is V2GalleryPreview) size(Size.ORIGINAL)
|
||||
memoryCacheKey(imageKey)
|
||||
diskCacheKey(imageKey)
|
||||
// Load in original size so the memory cache can be reused for preload requests
|
||||
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
|
||||
with(preview) {
|
||||
val key = if (this is V2GalleryPreview) getV2PreviewKey(url) else getThumbKey(url)
|
||||
data(url)
|
||||
size(SizeResolver.ORIGINAL)
|
||||
memoryCacheKey(key)
|
||||
diskCacheKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun ImageRequest.Builder.justDownload() = apply {
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
decoderFactory(BlackholeDecoder.Factory())
|
||||
}
|
||||
|
||||
@Composable
|
||||
inline fun <T : Any> PrefetchAround(data: LazyPagingItems<T>, index: Int, distance: Int, crossinline f: (T) -> ImageRequest) {
|
||||
data.peek((index - distance).coerceAtLeast(0))?.let { fetchBefore ->
|
||||
LaunchedEffect(fetchBefore) {
|
||||
f(fetchBefore).execute()
|
||||
}
|
||||
}
|
||||
data.peek((index + distance).coerceAtMost(data.itemCount - 1))?.let { fetchAhead ->
|
||||
LaunchedEffect(fetchAhead) {
|
||||
f(fetchAhead).execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.network.NetworkClient
|
||||
import coil3.network.NetworkRequest
|
||||
import coil3.network.NetworkResponse
|
||||
import coil3.network.ktor3.asNetworkClient
|
||||
import com.hippo.ehviewer.client.URL_PREFIX_THUMB_EX
|
||||
import com.hippo.ehviewer.client.URL_SIGNATURE_THUMB_NORMAL
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import moe.tarsin.coroutines.NamedSemaphore
|
||||
import moe.tarsin.coroutines.withLock
|
||||
|
||||
class LimitConcurrencyNetworkClient(val impl: NetworkClient) : NetworkClient {
|
||||
val semaphores = NamedSemaphore<String>(permits = 8)
|
||||
override suspend fun <T> executeRequest(req: NetworkRequest, f: suspend (NetworkResponse) -> T): T {
|
||||
val url = req.url
|
||||
return when {
|
||||
// Ex thumb server may not have h2 multiplexing support
|
||||
URL_PREFIX_THUMB_EX in url -> semaphores.withLock(URL_PREFIX_THUMB_EX) { withContext(NonCancellable) { impl.executeRequest(req, f) } }
|
||||
// H@H server may not have h2 multiplexing support
|
||||
URL_SIGNATURE_THUMB_NORMAL in url -> semaphores.withLock(url.substringBefore(URL_SIGNATURE_THUMB_NORMAL)) { withContext(NonCancellable) { impl.executeRequest(req, f) } }
|
||||
// H2 multiplexing enabled
|
||||
else -> impl.executeRequest(req, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun HttpClient.limitConcurrency() = LimitConcurrencyNetworkClient(asNetworkClient())
|
||||
@@ -1,24 +1,63 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import arrow.core.merge
|
||||
import arrow.fx.coroutines.raceN
|
||||
import coil3.decode.DataSource
|
||||
import coil3.imageLoader
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.isV2PreviewKey
|
||||
import moe.tarsin.coroutines.NamedMutex
|
||||
import moe.tarsin.coroutines.withLockNeedSuspend
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import moe.tarsin.coroutines.Counter
|
||||
import moe.tarsin.coroutines.Tracker
|
||||
import moe.tarsin.coroutines.counter
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import moe.tarsin.coroutines.use
|
||||
|
||||
typealias F = suspend () -> Unit
|
||||
|
||||
suspend inline fun <T> CancellableContinuation<T>.evalAndResume(f: suspend () -> T) = runSuspendCatching { f() }.takeIf { isActive }?.let(::resumeWith)
|
||||
|
||||
class TightRope : CoroutineScope, Counter by counter() {
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
val actions = Channel<F>()
|
||||
val flow = actions.receiveAsFlow().map(F::invoke).buffer(0).shareIn(this, SharingStarted.WhileSubscribed(stopTimeoutMillis = 200))
|
||||
suspend inline fun <R> sendAndAwait(crossinline block: suspend () -> R) = raceN(
|
||||
{ flow.collect {} },
|
||||
{ suspendCancellableCoroutine { cont -> launch { actions.send { cont.evalAndResume(block) } } } },
|
||||
).merge()
|
||||
}
|
||||
|
||||
object TightRopeTracker : Tracker<TightRope, String>() {
|
||||
override fun new() = TightRope()
|
||||
override fun free(e: TightRope) = e.cancel()
|
||||
}
|
||||
|
||||
object MergeInterceptor : Interceptor {
|
||||
private val mutex = NamedMutex<String>()
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val req = chain.request
|
||||
val key = req.memoryCacheKey?.takeIf { it.isV2PreviewKey || Settings.preloadThumbAggressively }
|
||||
val key = req.memoryCacheKey
|
||||
return if (key != null) {
|
||||
val (result, suspended) = mutex.withLockNeedSuspend(key) { chain.proceed() }
|
||||
val cacheKey = MemoryCache.Key(key, req.memoryCacheKeyExtras)
|
||||
val cached = req.context.imageLoader.memoryCache!![cacheKey] != null
|
||||
val result = TightRopeTracker.use(key) { sendAndAwait { chain.proceed() } }
|
||||
when (result) {
|
||||
is SuccessResult if (suspended) -> result.copy(dataSource = DataSource.MEMORY)
|
||||
is SuccessResult if !cached && result.dataSource == DataSource.MEMORY_CACHE -> {
|
||||
result.copy(dataSource = DataSource.MEMORY)
|
||||
}
|
||||
else -> result
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -3,12 +3,12 @@ package com.hippo.ehviewer.dailycheck
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.text.style.URLSpan
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.work.Constraints
|
||||
@@ -19,15 +19,15 @@ import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.ehviewer.core.util.withIOContext
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -66,7 +66,7 @@ private fun getDailyCheckWorkRequest(): PeriodicWorkRequest {
|
||||
private const val WORK_NAME = "DailyCheckWork"
|
||||
|
||||
fun updateDailyCheckWork(context: Context) {
|
||||
if (Settings.requestNews) {
|
||||
if (Settings.requestNews.value) {
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
|
||||
@@ -93,7 +93,7 @@ suspend fun checkDawn() = runCatching {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun showEventNotification(html: String) {
|
||||
if (Settings.hideHvEvents && html.contains("You have encountered a monster!")) {
|
||||
if (Settings.hideHvEvents.value && html.contains("You have encountered a monster!")) {
|
||||
return
|
||||
}
|
||||
val notificationManager = NotificationManagerCompat.from(appCtx)
|
||||
@@ -110,7 +110,7 @@ fun showEventNotification(html: String) {
|
||||
.setStyle(NotificationCompat.BigTextStyle())
|
||||
val urls = text.getSpans<URLSpan>(0, text.length)
|
||||
if (urls.isNotEmpty()) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(urls.first().url))
|
||||
val intent = Intent(Intent.ACTION_VIEW, urls.first().url.toUri())
|
||||
val pi = PendingIntentCompat.getActivity(appCtx, 0, intent, 0, false)
|
||||
msg.setContentIntent(pi)
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Entity(tableName = "HISTORY", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
|
||||
class HistoryInfo(gid: Long, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
|
||||
@@ -22,18 +22,31 @@ import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.util.size
|
||||
import arrow.fx.coroutines.parMapNotNull
|
||||
import com.ehviewer.core.data.model.asEntity
|
||||
import com.ehviewer.core.database.model.DownloadArtist
|
||||
import com.ehviewer.core.database.model.DownloadInfo
|
||||
import com.ehviewer.core.database.model.DownloadLabel
|
||||
import com.ehviewer.core.files.delete
|
||||
import com.ehviewer.core.files.find
|
||||
import com.ehviewer.core.files.toOkioPath
|
||||
import com.ehviewer.core.files.toUri
|
||||
import com.ehviewer.core.model.BaseGalleryInfo
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.preferences.edit
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.ehviewer.core.util.mapNotNull
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadArtist
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.DownloadLabel
|
||||
import com.hippo.ehviewer.client.exception.FatalException
|
||||
import com.hippo.ehviewer.spider.COMIC_INFO_FILE
|
||||
import com.hippo.ehviewer.spider.SpeedTracker
|
||||
import com.hippo.ehviewer.spider.SpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
|
||||
@@ -43,17 +56,10 @@ import com.hippo.ehviewer.spider.readCompatFromPath
|
||||
import com.hippo.ehviewer.spider.toSimpleTags
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.insertWith
|
||||
import com.hippo.ehviewer.util.mapNotNull
|
||||
import com.hippo.ehviewer.util.runAssertingNotMainThread
|
||||
import com.hippo.files.delete
|
||||
import com.hippo.files.find
|
||||
import com.hippo.files.toOkioPath
|
||||
import com.hippo.files.toUri
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.transform
|
||||
@@ -64,15 +70,12 @@ import logcat.LogPriority
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.Path.Companion.toPath
|
||||
import splitties.preferences.edit
|
||||
|
||||
object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
|
||||
// All download info list
|
||||
private val allInfoList = runAssertingNotMainThread {
|
||||
(EhDB.getAllDownloadInfo() as MutableList).apply { sortWith(comparator()) }
|
||||
}
|
||||
private val allInfoList = runAssertingNotMainThread { EhDB.getAllDownloadInfo() as MutableList }
|
||||
|
||||
val downloadInfoList: List<DownloadInfo>
|
||||
get() = allInfoList
|
||||
@@ -81,7 +84,10 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
private val mAllInfoMap = allInfoList.associateBy { it.gid } as MutableMap<Long, DownloadInfo>
|
||||
|
||||
// All labels without default label
|
||||
val labelList = runAssertingNotMainThread { EhDB.getAllDownloadLabelList() }.toMutableStateList()
|
||||
// Create the SnapshotStateList first in case the database query is time-consuming
|
||||
val labelList = mutableStateListOf<DownloadLabel>().apply {
|
||||
addAll(runAssertingNotMainThread { EhDB.getAllDownloadLabelList() })
|
||||
}
|
||||
|
||||
// Store download info wait to start
|
||||
private val mWaitList = ArrayDeque<DownloadInfo>()
|
||||
@@ -94,6 +100,21 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val sortMutex = Mutex()
|
||||
private val sortMode
|
||||
get() = SortMode.from(Settings.downloadSortMode.value)
|
||||
|
||||
var isInitialized by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
init {
|
||||
launch {
|
||||
val mode = sortMode
|
||||
if (mode != SortMode.Default) sortDownloads(mode)
|
||||
isInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
fun containLabel(label: String?): Boolean {
|
||||
if (label == null) {
|
||||
return false
|
||||
@@ -127,7 +148,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
mCurrentTask = info
|
||||
with(info) {
|
||||
archiveFile?.let {
|
||||
return onFinish(pages, pages, pages)
|
||||
return onFinish(pages, pages, pages, failed = false)
|
||||
}
|
||||
state = DownloadInfo.STATE_DOWNLOAD
|
||||
speed = -1
|
||||
@@ -175,11 +196,13 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
} else {
|
||||
// It is new download info
|
||||
info = DownloadInfo(galleryInfo, galleryInfo.downloadDirname())
|
||||
info = DownloadInfo(galleryInfo.asEntity(), galleryInfo.downloadDirname())
|
||||
info.label = label
|
||||
info.state = DownloadInfo.STATE_WAIT
|
||||
// Add to all download list and map
|
||||
allInfoList.insertWith(info, comparator())
|
||||
sortMutex.withLock {
|
||||
allInfoList.insertWith(info, sortMode.comparator())
|
||||
}
|
||||
mAllInfoMap[galleryInfo.gid] = info
|
||||
|
||||
// Add to wait list
|
||||
@@ -194,7 +217,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
ensureDownload()
|
||||
|
||||
// Add it to history
|
||||
EhDB.putHistoryInfo(info.galleryInfo)
|
||||
EhDB.putHistoryInfo(info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,10 +259,12 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}.collectAsState(transform(info)).value
|
||||
|
||||
suspend fun startAllDownload() {
|
||||
val updateList = allInfoList.filter {
|
||||
when (it.state) {
|
||||
DownloadInfo.STATE_NONE, DownloadInfo.STATE_FAILED -> true
|
||||
else -> false
|
||||
val updateList = sortMutex.withLock {
|
||||
allInfoList.filter {
|
||||
when (it.state) {
|
||||
DownloadInfo.STATE_NONE, DownloadInfo.STATE_FAILED -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updateList.isNotEmpty()) {
|
||||
@@ -253,8 +278,8 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addDownload(downloadInfoList: List<DownloadInfo>) {
|
||||
val comparator = comparator()
|
||||
suspend fun addDownload(downloadInfoList: List<DownloadInfo>) = sortMutex.withLock {
|
||||
val comparator = sortMode.comparator()
|
||||
downloadInfoList.forEach { info ->
|
||||
if (containDownloadInfo(info.gid)) return@forEach
|
||||
|
||||
@@ -284,11 +309,13 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
|
||||
suspend fun restoreDownload(galleryInfo: BaseGalleryInfo, dirname: String) {
|
||||
val info = DownloadInfo(galleryInfo, dirname)
|
||||
val info = DownloadInfo(galleryInfo.asEntity(), dirname)
|
||||
info.state = DownloadInfo.STATE_NONE
|
||||
|
||||
// Add to all download list and map
|
||||
allInfoList.insertWith(info, comparator())
|
||||
sortMutex.withLock {
|
||||
allInfoList.insertWith(info, sortMode.comparator())
|
||||
}
|
||||
mAllInfoMap[galleryInfo.gid] = info
|
||||
|
||||
// Save to
|
||||
@@ -308,16 +335,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopCurrentDownload() {
|
||||
val info = stopCurrentDownloadInternal()
|
||||
if (info != null) {
|
||||
// Update listener
|
||||
mutableNotifyFlow.emit(info)
|
||||
// Ensure download
|
||||
ensureDownload()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopRangeDownload(gidList: LongArray) {
|
||||
stopRangeDownloadInternal(gidList)
|
||||
|
||||
@@ -357,7 +374,9 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
EhDB.removeDownloadInfo(info)
|
||||
|
||||
// Remove all list and map
|
||||
allInfoList.remove(info)
|
||||
sortMutex.withLock {
|
||||
allInfoList.remove(info)
|
||||
}
|
||||
mAllInfoMap.remove(info.gid)
|
||||
|
||||
// Update listener
|
||||
@@ -380,7 +399,9 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
mAllInfoMap.remove(gid)
|
||||
}
|
||||
EhDB.removeDownloadInfo(list)
|
||||
allInfoList.removeAll(list.toSet())
|
||||
sortMutex.withLock {
|
||||
allInfoList.removeAll(list.toSet())
|
||||
}
|
||||
|
||||
// Update listener
|
||||
list.forEach { mutableNotifyFlow.emit(it) }
|
||||
@@ -389,7 +410,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
ensureDownload()
|
||||
}
|
||||
|
||||
fun sortDownloads(mode: SortMode) {
|
||||
suspend fun sortDownloads(mode: SortMode) = sortMutex.withLock {
|
||||
allInfoList.sortWith(mode.comparator())
|
||||
}
|
||||
|
||||
@@ -508,9 +529,11 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
val new = exist.copy(label = to)
|
||||
labelList.add(index, new)
|
||||
EhDB.updateDownloadLabel(new)
|
||||
allInfoList.forEach {
|
||||
if (it.label == from) {
|
||||
it.label = to
|
||||
sortMutex.withLock {
|
||||
allInfoList.forEach {
|
||||
if (it.label == from) {
|
||||
it.label = to
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -526,21 +549,25 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
removeAt(index)
|
||||
}
|
||||
allInfoList.forEach {
|
||||
if (it.label == label) {
|
||||
it.label = null
|
||||
sortMutex.withLock {
|
||||
allInfoList.forEach {
|
||||
if (it.label == label) {
|
||||
it.label = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readMetadataFromLocal() {
|
||||
val list = allInfoList.mapNotNull {
|
||||
val updateGallery = it.pages == 0 || it.simpleTags == null
|
||||
val updateArtist = it.artistInfoList.isEmpty()
|
||||
if (updateGallery || updateArtist) {
|
||||
Triple(it, updateGallery, updateArtist)
|
||||
} else {
|
||||
null
|
||||
val list = sortMutex.withLock {
|
||||
allInfoList.mapNotNull {
|
||||
val updateGallery = it.pages == 0 || it.simpleTags == null
|
||||
val updateArtist = it.artistInfoList.isEmpty()
|
||||
if (updateGallery || updateArtist) {
|
||||
Triple(it, updateGallery, updateArtist)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.parMapNotNull(concurrency = 5) { (info, updateGallery, updateArtist) ->
|
||||
info.downloadDir?.run {
|
||||
@@ -581,13 +608,14 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
}
|
||||
|
||||
val galleryInfoList = mutableListOf<BaseGalleryInfo>()
|
||||
list.forEach { (info, artists) ->
|
||||
info?.let {
|
||||
galleryInfoList.add(it)
|
||||
}
|
||||
artists?.let { (gid, updateList) ->
|
||||
EhDB.putDownloadArtist(gid, updateList)
|
||||
val galleryInfoList = buildList {
|
||||
list.forEach { (info, artists) ->
|
||||
info?.let {
|
||||
add(it)
|
||||
}
|
||||
artists?.let { (gid, updateList) ->
|
||||
EhDB.putDownloadArtist(gid, updateList)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (galleryInfoList.isNotEmpty()) EhDB.updateGalleryInfo(galleryInfoList)
|
||||
@@ -605,9 +633,9 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGet509(index: Int) {
|
||||
override fun onFatal(error: FatalException) {
|
||||
launch {
|
||||
mDownloadListener?.onGet509()
|
||||
mDownloadListener?.onFatal(error)
|
||||
stopAllDownload()
|
||||
}
|
||||
}
|
||||
@@ -654,7 +682,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinish(finished: Int, downloaded: Int, total: Int) {
|
||||
override fun onFinish(finished: Int, downloaded: Int, total: Int, failed: Boolean) {
|
||||
launch {
|
||||
mSpeedReminder.onFinish()
|
||||
mCurrentSpider?.let { spider ->
|
||||
@@ -669,11 +697,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
info.downloaded = downloaded
|
||||
info.total = total
|
||||
info.legacy = total - finished
|
||||
if (info.legacy == 0) {
|
||||
info.state = DownloadInfo.STATE_FINISH
|
||||
} else {
|
||||
info.state = DownloadInfo.STATE_FAILED
|
||||
}
|
||||
info.state = if (failed) DownloadInfo.STATE_FAILED else DownloadInfo.STATE_FINISH
|
||||
EhDB.putDownloadInfo(info)
|
||||
mDownloadListener?.onFinish(info)
|
||||
mutableNotifyFlow.emit(info)
|
||||
@@ -684,9 +708,9 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
|
||||
interface DownloadListener {
|
||||
/**
|
||||
* Get 509 error
|
||||
* Fatal error
|
||||
*/
|
||||
fun onGet509()
|
||||
fun onFatal(error: FatalException)
|
||||
|
||||
/**
|
||||
* Start download
|
||||
@@ -710,28 +734,24 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
|
||||
private val mSpeedReminder = object {
|
||||
private val mContentLengthMap = SparseLongArray()
|
||||
private val mReceivedSizeMap = SparseLongArray()
|
||||
private val contentLengthMap = SparseLongArray()
|
||||
private val receivedSizeMap = SparseLongArray()
|
||||
private val tracker = SpeedTracker()
|
||||
private val mutex = Mutex()
|
||||
private var mBytesRead = 0L
|
||||
private var oldSpeed = -1L
|
||||
private var currentJob: Job? = null
|
||||
fun start() {
|
||||
if (currentJob == null) {
|
||||
currentJob = launch {
|
||||
while (true) {
|
||||
runInternal()
|
||||
}
|
||||
tracker.speedFlow().collect(::updateSpeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stop() {
|
||||
mBytesRead = 0
|
||||
oldSpeed = -1
|
||||
tracker.reset()
|
||||
mutex.withLock {
|
||||
mContentLengthMap.clear()
|
||||
mReceivedSizeMap.clear()
|
||||
contentLengthMap.clear()
|
||||
receivedSizeMap.clear()
|
||||
}
|
||||
currentJob?.cancel()
|
||||
currentJob = null
|
||||
@@ -739,48 +759,43 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
|
||||
suspend fun onDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {
|
||||
mutex.withLock {
|
||||
mContentLengthMap.put(index, contentLength)
|
||||
mReceivedSizeMap.put(index, receivedSize)
|
||||
contentLengthMap.put(index, contentLength)
|
||||
receivedSizeMap.put(index, receivedSize)
|
||||
}
|
||||
mBytesRead += bytesRead.toLong()
|
||||
tracker.track(bytesRead)
|
||||
}
|
||||
|
||||
suspend fun onDone(index: Int) {
|
||||
mutex.withLock {
|
||||
mContentLengthMap.delete(index)
|
||||
mReceivedSizeMap.delete(index)
|
||||
contentLengthMap.delete(index)
|
||||
receivedSizeMap.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onFinish() {
|
||||
mutex.withLock {
|
||||
mContentLengthMap.clear()
|
||||
mReceivedSizeMap.clear()
|
||||
contentLengthMap.clear()
|
||||
receivedSizeMap.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runInternal() {
|
||||
private suspend fun updateSpeed(speed: Long) {
|
||||
mCurrentTask?.let { info ->
|
||||
var newSpeed = mBytesRead / 2
|
||||
if (oldSpeed != -1L) {
|
||||
newSpeed = lerp(oldSpeed, newSpeed, 0.75f)
|
||||
}
|
||||
oldSpeed = newSpeed
|
||||
info.speed = newSpeed
|
||||
info.speed = speed
|
||||
|
||||
// Calculate remaining
|
||||
if (info.total <= 0) {
|
||||
info.remaining = -1
|
||||
} else if (newSpeed == 0L) {
|
||||
} else if (speed == 0L) {
|
||||
info.remaining = 300L * 24L * 60L * 60L * 1000L // 300 days
|
||||
} else {
|
||||
var downloadingCount = 0
|
||||
var downloadingContentLengthSum = 0L
|
||||
var totalSize = 0L
|
||||
mutex.withLock {
|
||||
for (i in 0..<mContentLengthMap.size()) {
|
||||
val contentLength = mContentLengthMap.valueAt(i)
|
||||
val receivedSize = mReceivedSizeMap.valueAt(i)
|
||||
for (i in 0..<contentLengthMap.size) {
|
||||
val contentLength = contentLengthMap.valueAt(i)
|
||||
val receivedSize = receivedSizeMap.valueAt(i)
|
||||
downloadingCount++
|
||||
downloadingContentLengthSum += contentLength
|
||||
totalSize += contentLength - receivedSize
|
||||
@@ -788,20 +803,16 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
if (downloadingCount != 0) {
|
||||
totalSize += downloadingContentLengthSum * (info.total - info.downloaded - downloadingCount) / downloadingCount
|
||||
info.remaining = totalSize / newSpeed * 1000
|
||||
info.remaining = totalSize / speed * 1000
|
||||
}
|
||||
}
|
||||
mDownloadListener?.onDownload(info)
|
||||
mutableNotifyFlow.emit(info)
|
||||
}
|
||||
mBytesRead = 0
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "DownloadManager"
|
||||
|
||||
private fun comparator() = SortMode.from(Settings.downloadSortMode.value).comparator()
|
||||
}
|
||||
|
||||
var downloadLocation: Path
|
||||
@@ -830,4 +841,4 @@ var downloadLocation: Path
|
||||
|
||||
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
|
||||
val DownloadInfo.archiveFile get() = downloadDir?.run { find("$gid.cbz") ?: find("$gid.zip") }
|
||||
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")?.toOkioPath()
|
||||
val GalleryInfo.tempDownloadDir get() = AppConfig.externalTempPersistDir?.let { it / "$gid" }
|
||||
@@ -16,8 +16,10 @@
|
||||
package com.hippo.ehviewer.download
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ForegroundServiceStartNotAllowedException
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
@@ -26,17 +28,23 @@ import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import com.hippo.ehviewer.R
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.savedstate.serialization.decodeFromSavedState
|
||||
import androidx.savedstate.serialization.encodeToSavedState
|
||||
import com.ehviewer.core.database.model.DownloadInfo
|
||||
import com.ehviewer.core.i18n.R
|
||||
import com.ehviewer.core.model.BaseGalleryInfo
|
||||
import com.ehviewer.core.util.isAtLeastS
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.ehviewer.core.util.unsafeLazy
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.client.exception.FatalException
|
||||
import com.hippo.ehviewer.client.exception.InsufficientGpException
|
||||
import com.hippo.ehviewer.client.exception.IpBannedException
|
||||
import com.hippo.ehviewer.client.exception.QuotaExceededException
|
||||
import com.hippo.ehviewer.ui.MainActivity
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.getParcelableExtraCompat
|
||||
import com.hippo.ehviewer.util.unsafeLazy
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -44,8 +52,6 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DownloadService :
|
||||
@@ -57,7 +63,7 @@ class DownloadService :
|
||||
private val notifyManager by unsafeLazy { NotificationManagerCompat.from(this) }
|
||||
private val downloadingNotification by unsafeLazy { initDownloadingNotification() }
|
||||
private val downloadedNotification by lazy { initDownloadedNotification() }
|
||||
private val error509Notification by lazy { init509Notification() }
|
||||
private val fatalNotification by lazy { initFatalNotification() }
|
||||
private val channelId by unsafeLazy { "$packageName.download" }
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -65,12 +71,18 @@ class DownloadService :
|
||||
NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.download_service)).build(),
|
||||
)
|
||||
downloadingNotification.builder.run {
|
||||
downloadingNotification.builder.runCatching {
|
||||
setContentTitle(getString(R.string.download_service))
|
||||
.setContentText(null)
|
||||
.setSubText(null)
|
||||
.setProgress(0, 0, true)
|
||||
startForeground(ID_DOWNLOADING, build())
|
||||
}.onFailure {
|
||||
if (isAtLeastS && it is ForegroundServiceStartNotAllowedException) {
|
||||
logcat(it)
|
||||
} else {
|
||||
throw it
|
||||
}
|
||||
}
|
||||
launch {
|
||||
deferredMgr.await().setDownloadListener(this@DownloadService)
|
||||
@@ -95,61 +107,21 @@ class DownloadService :
|
||||
private suspend fun handleIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
val gi = intent.getParcelableExtraCompat<BaseGalleryInfo>(KEY_GALLERY_INFO)
|
||||
val gi = intent.getBundleExtra(KEY_GALLERY_INFO) ?: return
|
||||
val label = intent.getStringExtra(KEY_LABEL)
|
||||
if (gi != null) {
|
||||
deferredMgr.await().startDownload(gi, label)
|
||||
}
|
||||
deferredMgr.await().startDownload(decodeFromSavedState(gi), label)
|
||||
}
|
||||
|
||||
ACTION_START_RANGE -> {
|
||||
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
|
||||
if (gidList != null) {
|
||||
deferredMgr.await().startRangeDownload(gidList)
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_START_ALL -> {
|
||||
deferredMgr.await().startAllDownload()
|
||||
}
|
||||
|
||||
ACTION_STOP -> {
|
||||
val gid = intent.getLongExtra(KEY_GID, -1)
|
||||
if (gid != -1L) {
|
||||
deferredMgr.await().stopDownload(gid)
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_STOP_CURRENT -> deferredMgr.await().stopCurrentDownload()
|
||||
|
||||
ACTION_STOP_RANGE -> {
|
||||
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
|
||||
if (gidList != null) {
|
||||
deferredMgr.await().stopRangeDownload(gidList)
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_START_ALL -> deferredMgr.await().startAllDownload()
|
||||
ACTION_STOP_ALL -> deferredMgr.await().stopAllDownload()
|
||||
|
||||
ACTION_DELETE -> {
|
||||
val gid = intent.getLongExtra(KEY_GID, -1)
|
||||
if (gid != -1L) {
|
||||
deferredMgr.await().deleteDownload(gid)
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_DELETE_RANGE -> {
|
||||
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
|
||||
if (gidList != null) {
|
||||
deferredMgr.await().deleteRangeDownload(gidList)
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_CLEAR -> {
|
||||
clear()
|
||||
checkStopSelf()
|
||||
}
|
||||
ACTION_CLEAR -> clear()
|
||||
}
|
||||
checkStopSelf()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent) = null
|
||||
@@ -165,7 +137,7 @@ class DownloadService :
|
||||
.setAutoCancel(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
R.drawable.v_pause_x24,
|
||||
com.hippo.ehviewer.R.drawable.v_pause_x24,
|
||||
getString(R.string.stat_download_action_stop_all),
|
||||
piStopAll,
|
||||
)
|
||||
@@ -200,24 +172,27 @@ class DownloadService :
|
||||
.apply { launch { run() } }
|
||||
}
|
||||
|
||||
private fun init509Notification(): NotificationHandler {
|
||||
private fun initFatalNotification(): NotificationHandler {
|
||||
val builder = NotificationCompat.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(R.drawable.ic_baseline_warning_24)
|
||||
.setContentTitle(getString(R.string.stat_509_alert_title))
|
||||
.setContentText(getString(R.string.stat_509_alert_text))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle().bigText(getString(R.string.stat_509_alert_text)),
|
||||
)
|
||||
.setSmallIcon(com.hippo.ehviewer.R.drawable.ic_baseline_warning_24)
|
||||
.setAutoCancel(true)
|
||||
.setOngoing(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
return NotificationHandler(this, notifyManager, builder, ID_509)
|
||||
return NotificationHandler(this, notifyManager, builder, ID_FATAL)
|
||||
.apply { launch { run() } }
|
||||
}
|
||||
|
||||
override fun onGet509() {
|
||||
error509Notification.run {
|
||||
override fun onFatal(error: FatalException) {
|
||||
val (title, text) = when (error) {
|
||||
is IpBannedException -> R.string.error_ip_banned to error.message
|
||||
is InsufficientGpException -> R.string.insufficient_funds to error.message
|
||||
is QuotaExceededException -> R.string.stat_509_alert_title to getString(R.string.stat_509_alert_text)
|
||||
}
|
||||
fatalNotification.run {
|
||||
builder.setWhen(System.currentTimeMillis())
|
||||
.setContentTitle(getString(title))
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
show()
|
||||
}
|
||||
}
|
||||
@@ -246,7 +221,7 @@ class DownloadService :
|
||||
|
||||
override fun onDownload(info: DownloadInfo) {
|
||||
val speed = info.speed.coerceAtLeast(0)
|
||||
val speedText = FileUtils.humanReadableByteCount(speed, false) + "/s"
|
||||
val speedText = FileUtils.humanReadableByteCount(speed) + "/s"
|
||||
val remaining = info.remaining
|
||||
val text = if (remaining >= 0) {
|
||||
val interval = ReadableTime.getShortTimeInterval(remaining)
|
||||
@@ -372,7 +347,6 @@ class DownloadService :
|
||||
private fun checkStopSelf() {
|
||||
launch {
|
||||
if (deferredMgr.await().isIdle) {
|
||||
ServiceCompat.stopForeground(this@DownloadService, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
@@ -399,8 +373,8 @@ class DownloadService :
|
||||
}
|
||||
|
||||
suspend fun run() {
|
||||
channel.receiveAsFlow().sample(DELAY).collect {
|
||||
when (it) {
|
||||
while (true) {
|
||||
when (channel.receive()) {
|
||||
Ops.Notify -> {
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
service,
|
||||
@@ -410,10 +384,10 @@ class DownloadService :
|
||||
notifyManager.notify(id, builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
Ops.Cancel -> notifyManager.cancel(id)
|
||||
Ops.StartForeground -> service.startForeground(id, builder.build())
|
||||
}
|
||||
delay(DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,12 +405,7 @@ class DownloadService :
|
||||
const val ACTION_START = "start"
|
||||
const val ACTION_START_RANGE = "start_range"
|
||||
const val ACTION_START_ALL = "start_all"
|
||||
const val ACTION_STOP = "stop"
|
||||
const val ACTION_STOP_RANGE = "stop_range"
|
||||
const val ACTION_STOP_CURRENT = "stop_current"
|
||||
const val ACTION_STOP_ALL = "stop_all"
|
||||
const val ACTION_DELETE = "delete"
|
||||
const val ACTION_DELETE_RANGE = "delete_range"
|
||||
const val ACTION_CLEAR = "clear"
|
||||
const val KEY_GALLERY_INFO = "gallery_info"
|
||||
const val KEY_LABEL = "label"
|
||||
@@ -446,7 +415,7 @@ class DownloadService :
|
||||
const val ACTION_CLEAR_DOWNLOAD_SERVICE = "clear_download_service"
|
||||
private const val ID_DOWNLOADING = 1
|
||||
private const val ID_DOWNLOADED = 2
|
||||
private const val ID_509 = 3
|
||||
private const val ID_FATAL = 3
|
||||
private const val DELAY = 1000L // 1s
|
||||
private val sItemStateArray = LongSparseArray<Boolean>()
|
||||
private val sItemTitleArray = LongSparseArray<String>()
|
||||
@@ -454,6 +423,23 @@ class DownloadService :
|
||||
private var sFinishedCount = 0
|
||||
private var sDownloadedCount = 0
|
||||
|
||||
context(ctx: Context)
|
||||
inline fun startService(action: String, intentBuilder: Intent.() -> Unit = {}) {
|
||||
val intent = Intent(ctx, DownloadService::class.java).setAction(action).apply(intentBuilder)
|
||||
ContextCompat.startForegroundService(ctx, intent)
|
||||
}
|
||||
|
||||
context(_: Context)
|
||||
fun startDownload(info: BaseGalleryInfo, label: String? = null) = startService(ACTION_START) {
|
||||
putExtra(KEY_GALLERY_INFO, encodeToSavedState(info))
|
||||
label?.let { putExtra(KEY_LABEL, it) }
|
||||
}
|
||||
|
||||
context(_: Context)
|
||||
fun startRangeDownload(gidList: LongArray) = startService(ACTION_START_RANGE) {
|
||||
putExtra(KEY_GID_LIST, gidList)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
sFailedCount = 0
|
||||
sFinishedCount = 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.hippo.ehviewer.download
|
||||
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.artists
|
||||
import com.ehviewer.core.database.model.DownloadInfo
|
||||
import com.ehviewer.core.database.model.artists
|
||||
|
||||
enum class DownloadsFilterMode(
|
||||
val flag: Int,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.hippo.ehviewer.download
|
||||
|
||||
import com.ehviewer.core.database.model.DownloadInfo
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
|
||||
data class SortMode(val field: Field, val order: Order, val groupByDownloadLabel: Boolean = false) {
|
||||
val flag
|
||||
@@ -11,7 +11,6 @@ data class SortMode(val field: Field, val order: Order, val groupByDownloadLabel
|
||||
GID(0),
|
||||
TITLE(1),
|
||||
TIME(2),
|
||||
LABEL(3),
|
||||
PAGES(4),
|
||||
}
|
||||
|
||||
@@ -23,13 +22,16 @@ data class SortMode(val field: Field, val order: Order, val groupByDownloadLabel
|
||||
fun comparator(): Comparator<DownloadInfo> {
|
||||
var comparator = when (field) {
|
||||
Field.GID -> compareBy { it.gid }
|
||||
Field.TITLE, Field.LABEL -> compareBy { EhUtils.getSuitableTitle(it) }
|
||||
Field.TITLE -> compareBy { EhUtils.getSuitableTitle(it) }
|
||||
Field.TIME -> compareBy { it.time }
|
||||
Field.PAGES -> compareBy<DownloadInfo> { it.pages }.thenBy { EhUtils.getSuitableTitle(it) }
|
||||
Field.PAGES -> compareBy<DownloadInfo> { it.pages }
|
||||
}
|
||||
if (order == Order.DESC) {
|
||||
comparator = comparator.reversed()
|
||||
}
|
||||
if (field == Field.PAGES) {
|
||||
comparator = comparator.thenByDescending { it.time }
|
||||
}
|
||||
if (groupByDownloadLabel) {
|
||||
comparator = compareBy<DownloadInfo> { it.label }.then(comparator)
|
||||
}
|
||||
@@ -38,15 +40,16 @@ data class SortMode(val field: Field, val order: Order, val groupByDownloadLabel
|
||||
|
||||
companion object {
|
||||
fun from(flag: Int): SortMode {
|
||||
val field = flag and 0xF shr 1
|
||||
val order = flag and 0x1
|
||||
val fieldValue = flag and 0xF shr 1
|
||||
val orderValue = flag and 0x1
|
||||
val groupByDownloadLabel = flag shr 4 == 1
|
||||
require(field in Field.entries.indices && order in Order.entries.indices)
|
||||
return SortMode(Field.entries[field], Order.entries[order], groupByDownloadLabel)
|
||||
val field = Field.entries.find { it.value == fieldValue } ?: Default.field
|
||||
val order = Order.entries.find { it.value == orderValue } ?: Default.order
|
||||
return SortMode(field, order, groupByDownloadLabel)
|
||||
}
|
||||
val Default = SortMode(Field.TIME, Order.DESC)
|
||||
val All = listOf(
|
||||
SortMode(Field.TIME, Order.DESC),
|
||||
val All = [
|
||||
Default,
|
||||
SortMode(Field.TIME, Order.ASC),
|
||||
SortMode(Field.GID, Order.DESC),
|
||||
SortMode(Field.GID, Order.ASC),
|
||||
@@ -54,6 +57,6 @@ data class SortMode(val field: Field, val order: Order, val groupByDownloadLabel
|
||||
SortMode(Field.TITLE, Order.DESC),
|
||||
SortMode(Field.PAGES, Order.ASC),
|
||||
SortMode(Field.PAGES, Order.DESC),
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023 Tarsin Norbin
|
||||
* Copyright 2023-2024 Tarsin Norbin
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -15,10 +15,12 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import arrow.fx.coroutines.autoCloseable
|
||||
import arrow.fx.coroutines.closeable
|
||||
import arrow.fx.coroutines.resourceScope
|
||||
import arrow.autoCloseScope
|
||||
import com.ehviewer.core.files.openFileDescriptor
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.util.logcat
|
||||
import com.hippo.ehviewer.Settings.archivePasswds
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.image.ImageSource
|
||||
import com.hippo.ehviewer.image.byteBufferSource
|
||||
import com.hippo.ehviewer.jni.closeArchive
|
||||
@@ -29,40 +31,42 @@ import com.hippo.ehviewer.jni.needPassword
|
||||
import com.hippo.ehviewer.jni.openArchive
|
||||
import com.hippo.ehviewer.jni.providePassword
|
||||
import com.hippo.ehviewer.jni.releaseByteBuffer
|
||||
import com.hippo.ehviewer.ui.screen.implicit
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.displayName
|
||||
import com.hippo.files.openFileDescriptor
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import moe.tarsin.kt.install
|
||||
import okio.Path
|
||||
|
||||
typealias PasswdInvalidator = (String) -> Boolean
|
||||
typealias PasswdProvider = suspend (PasswdInvalidator) -> String
|
||||
val emptyPasswdProvider: PasswdProvider = { error("Managed Archive have password???") }
|
||||
|
||||
suspend fun <T> useArchivePageLoader(
|
||||
suspend inline fun <T> useArchivePageLoader(
|
||||
file: Path,
|
||||
gid: Long = 0,
|
||||
info: GalleryInfo? = null,
|
||||
startPage: Int = 0,
|
||||
hasAds: Boolean = false,
|
||||
passwdProvider: PasswdProvider = emptyPasswdProvider,
|
||||
block: suspend (PageLoader) -> T,
|
||||
) = coroutineScope {
|
||||
resourceScope {
|
||||
val pfd = closeable { file.openFileDescriptor("r") }
|
||||
crossinline passwdProvider: PasswdProvider,
|
||||
crossinline block: suspend (PageLoader) -> T,
|
||||
) = autoCloseScope {
|
||||
coroutineScope {
|
||||
val pfd = install(file.openFileDescriptor("r"))
|
||||
val size = install(
|
||||
{ openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip")) },
|
||||
{ openArchive(pfd.fd, pfd.statSize, info == null || file.name.endsWith(".zip")) },
|
||||
{ _, _ -> closeArchive() },
|
||||
)
|
||||
check(size >= 0) { "Archive have no content!" }
|
||||
if (needPassword() && archivePasswds.filterNotNull().none { providePassword(it) }) {
|
||||
archivePasswds += passwdProvider { providePassword(it) }
|
||||
check(size > 0) { "Archive have no content!" }
|
||||
if (needPassword() && archivePasswds.none(::providePassword)) {
|
||||
archivePasswds += passwdProvider(::providePassword)
|
||||
}
|
||||
val loader = autoCloseable {
|
||||
object : PageLoader(gid, startPage, size, hasAds, implicit<CoroutineScope>()) {
|
||||
override val title by lazy { FileUtils.getNameFromFilename(file.displayName)!! }
|
||||
val loader = install(
|
||||
object : PageLoader(this, info, startPage, size, hasAds) {
|
||||
override val title by lazy {
|
||||
if (info != null) {
|
||||
EhUtils.getSuitableTitle(info)
|
||||
} else {
|
||||
FileUtils.getNameFromFilename(file.displayName)!!
|
||||
}
|
||||
}
|
||||
|
||||
override fun getImageExtension(index: Int) = getExtension(index)
|
||||
|
||||
@@ -82,12 +86,11 @@ suspend fun <T> useArchivePageLoader(
|
||||
return byteBufferSource(buffer) { releaseByteBuffer(buffer) }
|
||||
}
|
||||
|
||||
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = pages.forEach(::notifySourceReady)
|
||||
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = Unit
|
||||
|
||||
override fun onRequest(index: Int, force: Boolean, orgImg: Boolean) = notifySourceReady(index)
|
||||
}
|
||||
}
|
||||
loader.progressJob.join()
|
||||
},
|
||||
)
|
||||
block(loader)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,30 @@
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import arrow.fx.coroutines.autoCloseable
|
||||
import arrow.fx.coroutines.resourceScope
|
||||
import arrow.autoCloseScope
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.spider.SpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
|
||||
import com.hippo.ehviewer.ui.screen.implicit
|
||||
import com.hippo.ehviewer.util.hasAds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import moe.tarsin.kt.install
|
||||
import okio.Path
|
||||
|
||||
suspend fun <T> useEhPageLoader(
|
||||
suspend inline fun <T> useEhPageLoader(
|
||||
info: GalleryInfo,
|
||||
startPage: Int,
|
||||
block: suspend (PageLoader) -> T,
|
||||
) = coroutineScope {
|
||||
resourceScope {
|
||||
crossinline block: suspend (PageLoader) -> T,
|
||||
) = autoCloseScope {
|
||||
coroutineScope {
|
||||
val queen = install(
|
||||
{ obtainSpiderQueen(info, SpiderQueen.MODE_READ) },
|
||||
{ queen, _ -> releaseSpiderQueen(queen, SpiderQueen.MODE_READ) },
|
||||
)
|
||||
queen.awaitReady()
|
||||
val loader = autoCloseable {
|
||||
object : PageLoader(info.gid, startPage, queen.size, info.hasAds, implicit<CoroutineScope>()) {
|
||||
val loader = install(
|
||||
object : PageLoader(this, info, startPage, queen.size, info.hasAds) {
|
||||
override val title by lazy { EhUtils.getSuitableTitle(info) }
|
||||
|
||||
override fun getImageExtension(index: Int) = queen.getExtension(index)
|
||||
@@ -38,8 +36,8 @@ suspend fun <T> useEhPageLoader(
|
||||
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = queen.preloadPages(pages, bounds)
|
||||
|
||||
override fun onRequest(index: Int, force: Boolean, orgImg: Boolean) = queen.request(index, force, orgImg)
|
||||
}
|
||||
}.apply {
|
||||
},
|
||||
).apply {
|
||||
val listener = object : OnSpiderListener {
|
||||
override fun onPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {
|
||||
if (contentLength > 0) {
|
||||
@@ -47,7 +45,7 @@ suspend fun <T> useEhPageLoader(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) = notifySourceReady(index)
|
||||
override fun onPageReady(index: Int) = notifySourceReady(index)
|
||||
|
||||
override fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int) = notifyPageFailed(index, error)
|
||||
}
|
||||
@@ -56,7 +54,6 @@ suspend fun <T> useEhPageLoader(
|
||||
{ _, _ -> queen.removeOnSpiderListener(listener) },
|
||||
)
|
||||
}
|
||||
loader.progressJob.join()
|
||||
block(loader)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import androidx.collection.SieveCache
|
||||
import arrow.atomic.value
|
||||
import androidx.collection.mutableIntObjectMapOf
|
||||
import arrow.fx.coroutines.ExitCase
|
||||
import arrow.fx.coroutines.bracketCase
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.util.isAtLeastO
|
||||
import com.ehviewer.core.util.withNonCancellableContext
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.image.Image
|
||||
@@ -12,13 +15,14 @@ import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.OSUtils
|
||||
import com.hippo.ehviewer.util.detectAds
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
import kotlin.concurrent.read
|
||||
import kotlin.concurrent.write
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -30,60 +34,49 @@ import okio.Path
|
||||
|
||||
private val progressScope = CoroutineScope(Dispatchers.IO)
|
||||
private const val MAX_CACHE_SIZE = 512 * 1024 * 1024
|
||||
private const val MIN_CACHE_SIZE = 128 * 1024 * 1024
|
||||
private const val MIN_CACHE_SIZE = 256 * 1024 * 1024
|
||||
|
||||
abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val hasAds: Boolean = false, val scope: CoroutineScope) : AutoCloseable {
|
||||
abstract class PageLoader(val scope: CoroutineScope, val info: GalleryInfo?, startPage: Int, val size: Int, val hasAds: Boolean = false) : AutoCloseable {
|
||||
var startPage = startPage.coerceIn(0, size - 1)
|
||||
|
||||
private val jobs = mutableIntObjectMapOf<Job>()
|
||||
private val mutex = NamedMutex<Int>()
|
||||
private val semaphore = Semaphore(4)
|
||||
|
||||
private val cache = SieveCache<Int, Image>(
|
||||
maxSize = if (isAtLeastO) {
|
||||
(OSUtils.totalMemory / 16).toInt().coerceIn(MIN_CACHE_SIZE, MAX_CACHE_SIZE)
|
||||
(OSUtils.totalMemory / 8).toInt().coerceIn(MIN_CACHE_SIZE, MAX_CACHE_SIZE)
|
||||
} else {
|
||||
(OSUtils.appMaxMemory / 3 * 2).toInt()
|
||||
},
|
||||
sizeOf = { _, v -> v.allocationSize.toInt() },
|
||||
onEntryRemoved = { k, o, n, _ ->
|
||||
if (o.isRecyclable) {
|
||||
n ?: notifyPageWait(k)
|
||||
o.recycle()
|
||||
} else {
|
||||
o.isRecyclable = true
|
||||
}
|
||||
},
|
||||
onEntryRemoved = { k, o, n, _ -> if (o.unpin()) n ?: notifyPageWait(k) },
|
||||
)
|
||||
|
||||
fun decodePreloadRange(index: Int) = index - 3..index + 3
|
||||
|
||||
fun needDecode(index: Int) = index in decodePreloadRange(prevIndex) &&
|
||||
lock.read { index !in cache } &&
|
||||
pages[index].status !is PageStatus.Ready &&
|
||||
pages[index].status !is PageStatus.Blocked
|
||||
|
||||
suspend fun atomicallyDecodeAndUpdate(index: Int) {
|
||||
try {
|
||||
bracketCase(
|
||||
{ openSource(index) },
|
||||
{ src -> notifyPageSucceed(index, Image.decode(src, hasAds && detectAds(index, size))) },
|
||||
{ src, case -> if (case !is ExitCase.Completed) src.close() },
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
notifyPageFailed(index, e.displayString())
|
||||
}
|
||||
private suspend fun atomicallyDecodeAndUpdate(index: Int) {
|
||||
bracketCase(
|
||||
{ openSource(index) },
|
||||
{ src ->
|
||||
withNonCancellableContext {
|
||||
notifyPageSucceed(index, Image.decode(src, hasAds && detectAds(index, size)))
|
||||
}
|
||||
},
|
||||
{ src, case -> if (case !is ExitCase.Completed) src.close() },
|
||||
)
|
||||
}
|
||||
|
||||
private val lock = ReentrantReadWriteLock()
|
||||
|
||||
val pages = (0 until size).map { Page(it) }
|
||||
|
||||
private val prefetchPageCount = Settings.preloadImage
|
||||
private val prefetchPageCount = Settings.preloadImage.value
|
||||
|
||||
fun restart() {
|
||||
lock.write { cache.evictAll() }
|
||||
pages.forEach(Page::reset)
|
||||
}
|
||||
|
||||
private var prevIndex by AtomicInteger(-1)::value
|
||||
private val prevIndex = AtomicInt(-1)
|
||||
|
||||
fun retryPage(index: Int, orgImg: Boolean = false) {
|
||||
notifyPageWait(index)
|
||||
@@ -117,22 +110,16 @@ abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val
|
||||
pages[index].statusFlow.update { PageStatus.Error(error) }
|
||||
}
|
||||
|
||||
val progressJob = progressScope.launch {
|
||||
if (startPage == -1) {
|
||||
startPage = EhDB.getReadProgress(gid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
lock.write { cache.evictAll() }
|
||||
if (gid != 0L) {
|
||||
info?.run {
|
||||
progressScope.launch {
|
||||
EhDB.putReadProgress(gid, startPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract val title: String
|
||||
abstract val title: String
|
||||
|
||||
protected abstract fun getImageExtension(index: Int): String?
|
||||
|
||||
@@ -141,12 +128,12 @@ abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val
|
||||
}
|
||||
|
||||
fun request(index: Int) {
|
||||
val prefetchRange = if (index >= prevIndex) {
|
||||
val prefetchRange = if (index >= prevIndex.load()) {
|
||||
index + 1..(index + prefetchPageCount).coerceAtMost(size - 1)
|
||||
} else {
|
||||
index - 1 downTo (index - prefetchPageCount).coerceAtLeast(0)
|
||||
}
|
||||
prevIndex = index
|
||||
prevIndex.store(index)
|
||||
val image = lock.read { cache[index] }
|
||||
if (image != null) {
|
||||
notifyPageSucceed(index, image, false)
|
||||
@@ -167,15 +154,27 @@ abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val
|
||||
prefetchPages(pagesAbsent, start - 5..end + 5)
|
||||
}
|
||||
|
||||
fun cancelRequest(index: Int) {
|
||||
jobs[index]?.cancel()
|
||||
}
|
||||
|
||||
abstract fun save(index: Int, file: Path): Boolean
|
||||
|
||||
fun notifySourceReady(index: Int) {
|
||||
if (needDecode(index)) {
|
||||
scope.launch {
|
||||
mutex.withLock(index) {
|
||||
semaphore.withPermit {
|
||||
// Double check
|
||||
if (needDecode(index)) atomicallyDecodeAndUpdate(index)
|
||||
fun notifySourceReady(index: Int) = synchronized(jobs) {
|
||||
if (jobs[index]?.isActive != true) {
|
||||
jobs[index] = scope.launch {
|
||||
try {
|
||||
mutex.withLock(index) {
|
||||
semaphore.withPermit {
|
||||
atomicallyDecodeAndUpdate(index)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) {
|
||||
notifyPageFailed(index, null)
|
||||
throw e
|
||||
} else {
|
||||
notifyPageFailed(index, e.displayString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,21 @@ import arrow.fx.coroutines.bracketCase
|
||||
import coil3.BitmapImage
|
||||
import coil3.DrawableImage
|
||||
import coil3.Image as CoilImage
|
||||
import coil3.imageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.allowHardware
|
||||
import coil3.size.Dimension
|
||||
import coil3.request.maxBitmapSize
|
||||
import coil3.size.Precision
|
||||
import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.SizeResolver
|
||||
import com.ehviewer.core.files.openFileDescriptor
|
||||
import com.ehviewer.core.files.toUri
|
||||
import com.ehviewer.core.util.isAtLeastP
|
||||
import com.ehviewer.core.util.isAtLeastU
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.coil.AnimatedWebPDrawable
|
||||
import com.hippo.ehviewer.coil.BitmapImageWithExtraInfo
|
||||
import com.hippo.ehviewer.coil.detectQrCode
|
||||
import com.hippo.ehviewer.coil.hardwareThreshold
|
||||
@@ -44,16 +51,22 @@ import com.hippo.ehviewer.jni.isGif
|
||||
import com.hippo.ehviewer.jni.mmap
|
||||
import com.hippo.ehviewer.jni.munmap
|
||||
import com.hippo.ehviewer.jni.rewriteGifSource
|
||||
import com.hippo.ehviewer.ktbuilder.execute
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastU
|
||||
import com.hippo.files.openFileDescriptor
|
||||
import com.hippo.files.toUri
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
import kotlin.concurrent.atomics.decrementAndFetch
|
||||
import kotlin.concurrent.atomics.updateAndFetch
|
||||
import okio.Path
|
||||
import splitties.init.appCtx
|
||||
|
||||
class Image private constructor(image: CoilImage, private val src: ImageSource) {
|
||||
val refcnt = AtomicInt(1)
|
||||
|
||||
fun pin() = refcnt.updateAndFetch { if (it != 0) it + 1 else 0 } != 0
|
||||
|
||||
fun unpin() = (refcnt.decrementAndFetch() == 0).also { if (it) recycle() }
|
||||
|
||||
val intrinsicSize = with(image) { IntSize(width, height) }
|
||||
val allocationSize = image.size
|
||||
val hasQrCode = when (image) {
|
||||
@@ -66,33 +79,40 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
|
||||
else -> image
|
||||
}
|
||||
|
||||
var isRecyclable = true
|
||||
|
||||
@Synchronized
|
||||
fun recycle() {
|
||||
when (val image = innerImage ?: return) {
|
||||
is DrawableImage -> src.close()
|
||||
private fun recycle() {
|
||||
when (val image = innerImage!!) {
|
||||
is DrawableImage -> {
|
||||
(image.drawable as? AnimatedWebPDrawable)?.dispose()
|
||||
src.close()
|
||||
}
|
||||
is BitmapImage -> image.bitmap.recycle()
|
||||
}
|
||||
innerImage = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
|
||||
private val sizeResolver = with(appCtx.resources.displayMetrics) {
|
||||
val targetSize = minOf(widthPixels, heightPixels) * 4 / 3
|
||||
SizeResolver(Size(targetSize, targetSize))
|
||||
}
|
||||
|
||||
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(checkExtraneousAds: Boolean): CoilImage {
|
||||
val req = appCtx.imageRequest {
|
||||
onLeft { data(it.source) }
|
||||
onRight { data(it.source.toUri()) }
|
||||
size(Dimension(targetWidth), Dimension.Undefined)
|
||||
precision(Precision.INEXACT)
|
||||
allowHardware(false)
|
||||
hardwareThreshold(Settings.hardwareBitmapThreshold)
|
||||
maybeCropBorder(Settings.cropBorder.value)
|
||||
detectQrCode(checkExtraneousAds)
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
val request = with(appCtx) {
|
||||
imageRequest {
|
||||
onLeft { data(it.source) }
|
||||
onRight { data(it.source.toUri()) }
|
||||
size(sizeResolver)
|
||||
scale(Scale.FILL)
|
||||
precision(Precision.INEXACT)
|
||||
maxBitmapSize(Size.ORIGINAL)
|
||||
allowHardware(false)
|
||||
hardwareThreshold(Settings.hardwareBitmapThreshold.value)
|
||||
maybeCropBorder(Settings.cropBorder.value)
|
||||
detectQrCode(checkExtraneousAds)
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
}
|
||||
}
|
||||
return when (val result = appCtx.imageLoader.execute(req)) {
|
||||
return when (val result = request.execute()) {
|
||||
is SuccessResult -> result.image
|
||||
is ErrorResult -> throw result.throwable
|
||||
}
|
||||
@@ -115,7 +135,6 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
|
||||
}
|
||||
src.right().decodeCoil(checkExtraneousAds)
|
||||
}
|
||||
|
||||
is ByteBufferSource -> {
|
||||
if (isAtLeastP && !isAtLeastU) {
|
||||
rewriteGifSource(src.source)
|
||||
|
||||
@@ -5,16 +5,20 @@ import coil3.ImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.model.GalleryPreview
|
||||
import com.hippo.ehviewer.coil.ehPreview
|
||||
import com.hippo.ehviewer.coil.ehUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
inline fun Context.imageRequest(preview: GalleryPreview, builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(this).ehPreview(preview).apply(builder).build()
|
||||
inline fun Context.imageRequest(info: GalleryInfo, builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(this).ehUrl(info).apply(builder).build()
|
||||
inline fun Context.imageRequest(builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(this).apply(builder).build()
|
||||
context(ctx: Context)
|
||||
inline fun imageRequest(preview: GalleryPreview, builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(ctx).ehPreview(preview).apply(builder).build()
|
||||
context(ctx: Context)
|
||||
inline fun imageRequest(info: GalleryInfo, builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(ctx).ehUrl(info).apply(builder).build()
|
||||
context(ctx: Context)
|
||||
inline fun imageRequest(builder: ImageRequest.Builder.() -> Unit = {}) = ImageRequest.Builder(ctx).apply(builder).build()
|
||||
inline fun diskCache(builder: DiskCache.Builder.() -> Unit) = DiskCache.Builder().apply(builder).build()
|
||||
inline fun Context.imageLoader(builder: ImageLoader.Builder.() -> Unit) = ImageLoader.Builder(this).apply(builder).build()
|
||||
fun ImageRequest.launchIn(scope: CoroutineScope) = scope.launch { context.imageLoader.execute(this@launchIn) }
|
||||
suspend fun ImageRequest.execute() = context.imageLoader.execute(this)
|
||||
fun ImageRequest.executeIn(scope: CoroutineScope) = scope.launch { execute() }
|
||||
@@ -1,36 +1,57 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import com.hippo.ehviewer.Settings.userAgent
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import androidx.webkit.WebViewCompat
|
||||
import com.ehviewer.core.network.EhCookieStore
|
||||
import com.hippo.ehviewer.Settings
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.HttpClientEngineConfig
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.SaveBodyPlugin
|
||||
import io.ktor.client.plugins.UserAgent
|
||||
import io.ktor.client.plugins.HttpTimeoutConfig
|
||||
import io.ktor.client.plugins.api.createClientPlugin
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.userAgent
|
||||
import io.ktor.util.appendIfNameAbsent
|
||||
import splitties.init.appCtx
|
||||
|
||||
private const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
|
||||
private const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
|
||||
// It's safe to assume the WebView package will always be present as we require CookieManager anyway
|
||||
private val WebViewVersion = WebViewCompat.getCurrentWebViewPackage(appCtx)!!.versionName!!.substringBefore('.')
|
||||
val CHROME_MOBILE_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$WebViewVersion.0.0.0 Mobile Safari/537.36"
|
||||
private val CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$WebViewVersion.0.0.0 Safari/537.36"
|
||||
private const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
|
||||
private const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.9"
|
||||
|
||||
fun <T : HttpClientEngineConfig> HttpClientConfig<T>.configureCommon(redirect: Boolean = true) = apply {
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 10_000
|
||||
}
|
||||
install(UserAgent) {
|
||||
agent = userAgent
|
||||
reset()
|
||||
requestTimeoutMillis = 30_000
|
||||
}
|
||||
install(UserAgent)
|
||||
defaultRequest {
|
||||
header(HttpHeaders.Accept, CHROME_ACCEPT)
|
||||
headers.appendIfNameAbsent(HttpHeaders.Accept, CHROME_ACCEPT)
|
||||
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
|
||||
}
|
||||
followRedirects = redirect
|
||||
install(SaveBodyPlugin) {
|
||||
disabled = true
|
||||
}
|
||||
|
||||
private val UserAgent = createClientPlugin("UserAgent") {
|
||||
onRequest { request, _ ->
|
||||
val userAgent = if (Settings.desktopSite.value) {
|
||||
CHROME_USER_AGENT
|
||||
} else {
|
||||
CHROME_MOBILE_USER_AGENT
|
||||
}
|
||||
request.userAgent(userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
fun HttpTimeoutConfig.reset() = apply {
|
||||
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
|
||||
connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
|
||||
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import android.net.http.UrlRequest
|
||||
import android.net.http.UrlResponseInfo
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresExtension
|
||||
import com.hippo.ehviewer.Settings
|
||||
import io.ktor.client.engine.HttpClientEngineBase
|
||||
import io.ktor.client.engine.callContext
|
||||
import io.ktor.client.plugins.HttpTimeoutCapability
|
||||
@@ -33,20 +32,21 @@ import java.nio.ByteBuffer
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cronet") {
|
||||
// Limit thread to 1 since we are async & non-blocking
|
||||
override val dispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
// TODO: Investigate https://github.com/FooIbar/EhViewer/issues/2854
|
||||
// override val dispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
|
||||
override val supportedCapabilities = setOf(HttpTimeoutCapability)
|
||||
|
||||
private val executor = dispatcher.asExecutor()
|
||||
private val executor = dispatcher.limitedParallelism(1).asExecutor()
|
||||
private val pool = DirectByteBufferPool(32)
|
||||
private val client by lazy {
|
||||
with(config) { HttpEngine.Builder(context).apply(config).build() }
|
||||
@@ -93,7 +93,7 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr
|
||||
}
|
||||
|
||||
override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer) {
|
||||
chunkChan.trySend(byteBuffer).getOrThrow()
|
||||
chunkChan.trySendBlocking(byteBuffer).getOrThrow()
|
||||
}
|
||||
|
||||
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||
@@ -101,11 +101,6 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr
|
||||
}
|
||||
|
||||
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: HttpException) {
|
||||
// Cronet may crash on some devices, fuck xiaomi
|
||||
// https://github.com/FooIbar/EhViewer/issues/1826
|
||||
if (error.message?.contains("ERR_FILE_NOT_FOUND") == true) {
|
||||
Settings.enableCronet = false
|
||||
}
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWithException(error)
|
||||
} else {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
|
||||
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import android.net.http.HttpEngine
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresExtension
|
||||
import com.ehviewer.core.util.isAtLeastSExtension7
|
||||
import java.io.File
|
||||
|
||||
fun CronetConfig.configureClient() {
|
||||
@RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
|
||||
fun CronetConfig.configureClient(enableQuic: Boolean) {
|
||||
config = {
|
||||
setEnableBrotli(true)
|
||||
|
||||
@@ -16,8 +16,22 @@ fun CronetConfig.configureClient() {
|
||||
setStoragePath(cache.path)
|
||||
setEnableHttpCache(HttpEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
|
||||
|
||||
addQuicHint("e-hentai.org", 443, 443)
|
||||
addQuicHint("forums.e-hentai.org", 443, 443)
|
||||
addQuicHint("exhentai.org", 443, 443)
|
||||
setEnableQuic(enableQuic)
|
||||
if (enableQuic) {
|
||||
addQuicHint("e-hentai.org", 443, 443)
|
||||
addQuicHint("forums.e-hentai.org", 443, 443)
|
||||
addQuicHint("exhentai.org", 443, 443)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isCronetAvailable: Boolean
|
||||
get() = isAtLeastSExtension7 && !isDeviceBlocked
|
||||
|
||||
// https://github.com/FooIbar/EhViewer/issues/1826
|
||||
private val isDeviceBlocked = when (Build.VERSION.INCREMENTAL.substringBefore('.')) {
|
||||
"V816", // HyperOS 1
|
||||
"OS2", // HyperOS 2
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhDns
|
||||
import com.hippo.ehviewer.client.EhSSLSocketFactory
|
||||
import com.hippo.ehviewer.client.install
|
||||
import io.ktor.client.engine.okhttp.OkHttpConfig
|
||||
import okhttp3.AsyncDns
|
||||
import okhttp3.ExperimentalOkHttpApi
|
||||
import okhttp3.android.AndroidAsyncDns
|
||||
import java.net.Proxy
|
||||
|
||||
@OptIn(ExperimentalOkHttpApi::class)
|
||||
fun OkHttpConfig.configureClient() {
|
||||
config {
|
||||
if (isAtLeastQ) {
|
||||
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
|
||||
dns(EhDns)
|
||||
proxy(Proxy.NO_PROXY)
|
||||
if (!Settings.enableQuic.value) {
|
||||
install(EhSSLSocketFactory)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,36 @@
|
||||
package com.hippo.ehviewer.legacy
|
||||
|
||||
import com.ehviewer.core.files.read
|
||||
import com.hippo.ehviewer.spider.SpiderInfo
|
||||
import java.io.InputStream
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import kotlinx.io.readLineStrict
|
||||
import okio.Path
|
||||
|
||||
fun readLegacySpiderInfo(inputStream: InputStream): SpiderInfo {
|
||||
val source = inputStream.source().buffer()
|
||||
fun read(): String = source.readUtf8LineStrict()
|
||||
fun Path.readLegacySpiderInfo() = read {
|
||||
fun read() = readLineStrict()
|
||||
fun readInt() = read().toInt()
|
||||
fun readLong() = read().toLong()
|
||||
|
||||
fun readInt(): Int = read().toInt()
|
||||
|
||||
fun readLong(): Long = read().toLong()
|
||||
repeat(2) { read() } // We assert that only info v2
|
||||
read() // Skip version, we assert it's v2
|
||||
read() // Skip startPage
|
||||
val gid = readLong()
|
||||
val token = read()
|
||||
read()
|
||||
val previewPages = readInt()
|
||||
read() // Skip mode
|
||||
read() // Skip previewPages
|
||||
val previewPerPage = readInt()
|
||||
val pages = read().toInt()
|
||||
val pTokenMap = hashMapOf<Int, String>()
|
||||
val info = SpiderInfo(gid, token, pages, pTokenMap, previewPages, previewPerPage)
|
||||
runCatching {
|
||||
while (true) {
|
||||
val line = read()
|
||||
val pos = line.indexOf(" ")
|
||||
if (pos > 0) {
|
||||
val index = line.substring(0, pos).toInt()
|
||||
val pToken = line.substring(pos + 1)
|
||||
if (pToken.isNotEmpty()) {
|
||||
pTokenMap[index] = pToken
|
||||
val pages = readInt()
|
||||
SpiderInfo(gid, token, pages, previewPerPage = previewPerPage).apply {
|
||||
runCatching {
|
||||
while (true) {
|
||||
val line = read()
|
||||
val pos = line.indexOf(" ")
|
||||
if (pos > 0) {
|
||||
val index = line.substring(0, pos).toInt()
|
||||
val pToken = line.substring(pos + 1)
|
||||
if (pToken.isNotEmpty()) {
|
||||
pTokenMap[index] = pToken
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -15,10 +15,8 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.shortcuts
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hippo.ehviewer.download.DownloadService
|
||||
|
||||
/**
|
||||
@@ -27,8 +25,8 @@ import com.hippo.ehviewer.download.DownloadService
|
||||
class ShortcutsActivity : AppCompatActivity() {
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
when (intent?.action) {
|
||||
DownloadService.ACTION_START_ALL, DownloadService.ACTION_STOP_ALL -> ContextCompat.startForegroundService(this, Intent(this, DownloadService::class.java).setAction(intent.action))
|
||||
when (val action = intent?.action) {
|
||||
DownloadService.ACTION_START_ALL, DownloadService.ACTION_STOP_ALL -> DownloadService.startService(action)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
package com.hippo.ehviewer.spider
|
||||
|
||||
import com.ehviewer.core.database.util.SimpleTagsConverter
|
||||
import com.ehviewer.core.files.read
|
||||
import com.ehviewer.core.files.write
|
||||
import com.ehviewer.core.model.GalleryDetail
|
||||
import com.ehviewer.core.model.GalleryInfo
|
||||
import com.ehviewer.core.model.GalleryTag
|
||||
import com.ehviewer.core.model.PowerStatus
|
||||
import com.ehviewer.core.model.TagNamespace
|
||||
import com.ehviewer.core.model.TagNamespace.Artist
|
||||
import com.ehviewer.core.model.TagNamespace.Character
|
||||
import com.ehviewer.core.model.TagNamespace.Cosplayer
|
||||
import com.ehviewer.core.model.TagNamespace.Female
|
||||
import com.ehviewer.core.model.TagNamespace.Group
|
||||
import com.ehviewer.core.model.TagNamespace.Location
|
||||
import com.ehviewer.core.model.TagNamespace.Male
|
||||
import com.ehviewer.core.model.TagNamespace.Mixed
|
||||
import com.ehviewer.core.model.TagNamespace.Other
|
||||
import com.ehviewer.core.model.TagNamespace.Parody
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.SimpleTagsConverter
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Artist
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Character
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Cosplayer
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Female
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Group
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Male
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Mixed
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Other
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Parody
|
||||
import com.hippo.files.openInputStream
|
||||
import com.hippo.files.openOutputStream
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -23,12 +26,12 @@ import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import net.devrieze.xmlutil.serialization.kxio.decodeFromSource
|
||||
import net.devrieze.xmlutil.serialization.kxio.encodeToSink
|
||||
import nl.adaptivity.xmlutil.XmlDeclMode
|
||||
import nl.adaptivity.xmlutil.core.XmlVersion
|
||||
import nl.adaptivity.xmlutil.newWriter
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
import nl.adaptivity.xmlutil.serialization.XmlElement
|
||||
import nl.adaptivity.xmlutil.xmlStreaming
|
||||
import okio.Path
|
||||
|
||||
const val COMIC_INFO_FILE = "ComicInfo.xml"
|
||||
@@ -40,7 +43,7 @@ private val xml = XML {
|
||||
}
|
||||
xmlDeclMode = XmlDeclMode.Charset
|
||||
xmlVersion = XmlVersion.XML10
|
||||
indent = 2
|
||||
setIndent(2)
|
||||
}
|
||||
|
||||
fun GalleryInfo.getComicInfo(): ComicInfo {
|
||||
@@ -52,20 +55,19 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
|
||||
with(TagNamespace) {
|
||||
when (this@getComicInfo) {
|
||||
is GalleryDetail -> tagGroups.forEach { group ->
|
||||
val list = group.tags.filterNot { it == TAG_ORIGINAL || it.startsWith('_') }
|
||||
when (val ns = group.nameSpace) {
|
||||
val list = group.tags.filterNot { (text, power, _) -> text == TAG_ORIGINAL || power == PowerStatus.Weak }.map(GalleryTag::text)
|
||||
when (val ns = group.namespace) {
|
||||
Artist, Cosplayer -> artists.addAll(list)
|
||||
Group -> groups.addAll(list)
|
||||
Character -> characters.addAll(list)
|
||||
Parody -> parodies.addAll(list)
|
||||
Other -> otherTags.addAll(list)
|
||||
Location, Other -> otherTags.addAll(list)
|
||||
Female, Male, Mixed -> ns.prefix.let { prefix ->
|
||||
list.forEach { tag -> otherTags.add("$prefix:$tag") }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
else -> simpleTags?.forEach { tagString ->
|
||||
val (namespace, tag) = tagString.split(':', limit = 2)
|
||||
.takeIf { it.size == 2 } ?: return@forEach // Ignore temp tags that don't have namespace
|
||||
@@ -74,7 +76,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
|
||||
Group -> groups.add(tag)
|
||||
Character -> characters.add(tag)
|
||||
Parody -> if (tag != TAG_ORIGINAL) parodies.add(tag)
|
||||
Other -> otherTags.add(tag)
|
||||
Location, Other -> otherTags.add(tag)
|
||||
Female, Male, Mixed -> ns.prefix.let { otherTags.add("$it:$tag") }
|
||||
else -> Unit
|
||||
}
|
||||
@@ -104,19 +106,11 @@ fun ComicInfo.toSimpleTags() = listOfNotNull(
|
||||
teams,
|
||||
).flatten().ifEmpty { null }
|
||||
|
||||
fun ComicInfo.write(file: Path) {
|
||||
file.openOutputStream().bufferedWriter().use {
|
||||
xmlStreaming.newWriter(it).use { writer ->
|
||||
xml.encodeToWriter(writer, ComicInfo.serializer(), this)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun writeComicInfo(info: ComicInfo, file: Path) = file.write { xml.encodeToSink(this, info) }
|
||||
|
||||
fun readComicInfo(file: Path): ComicInfo? = runCatching {
|
||||
file.openInputStream().bufferedReader().use {
|
||||
xmlStreaming.newReader(it).use { reader ->
|
||||
xml.decodeFromReader(ComicInfo.serializer(), reader)
|
||||
}
|
||||
file.read {
|
||||
xml.decodeFromSource<ComicInfo>(this)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
|
||||
Loaded 100 of 479 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user