Compare commits
626 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea1122ca0 | ||
|
|
4faef85086 | ||
|
|
b46c00f2d0 | ||
|
|
9358617a3a | ||
|
|
ba9f31835f | ||
|
|
357308bfbb | ||
|
|
cab56209c1 | ||
|
|
e9cd32c870 | ||
|
|
357517ceac | ||
|
|
a57fcce72b | ||
|
|
2e2a818c05 | ||
|
|
b6f618101f | ||
|
|
0ce368751a | ||
|
|
1d28538893 | ||
|
|
4ad2f3f608 | ||
|
|
5301cc7f97 | ||
|
|
1290db4a7c | ||
|
|
1f1309d934 | ||
|
|
350f1521a6 | ||
|
|
cebce20bed | ||
|
|
e5b6947586 | ||
|
|
ac96c49b60 | ||
|
|
a4345a40bf | ||
|
|
f518acb8ee | ||
|
|
b39a51d497 | ||
|
|
8819d8b1ee | ||
|
|
05a502b89a | ||
|
|
c320e3c26a | ||
|
|
938849c31e | ||
|
|
95c243daa1 | ||
|
|
6ce6a02b56 | ||
|
|
e92e9fb393 | ||
|
|
f4186a2787 | ||
|
|
8b93b699d3 | ||
|
|
7e13482ba5 | ||
|
|
04700a22c8 | ||
|
|
549d08cc06 | ||
|
|
0fccaf3fbc | ||
|
|
c7e0a47bee | ||
|
|
d527b6e390 | ||
|
|
12b2af6b93 | ||
|
|
63f4fab40f | ||
|
|
9a444cf965 | ||
|
|
b8be2f7158 | ||
|
|
9e2074040f | ||
|
|
020c151e31 | ||
|
|
52eb33a992 | ||
|
|
907b8fd0ec | ||
|
|
e35b2088a1 | ||
|
|
fbb4efb3df | ||
|
|
7ff47a322e | ||
|
|
fda1af5500 | ||
|
|
d88847d137 | ||
|
|
063527b240 | ||
|
|
b0470110a8 | ||
|
|
5a2a31d1c8 | ||
|
|
3b009d7c55 | ||
|
|
f7e937f2b8 | ||
|
|
16e23cc1cf | ||
|
|
d12528d80f | ||
|
|
9f04c7b148 | ||
|
|
7a3942f100 | ||
|
|
8e46f64f2a | ||
|
|
44c50fca2d | ||
|
|
55b4d14a93 | ||
|
|
743693299f | ||
|
|
7950a685a6 | ||
|
|
97cfcb5c01 | ||
|
|
b2dfcefee8 | ||
|
|
ee1ade40c3 | ||
|
|
3690e15cff | ||
|
|
a955dfbe50 | ||
|
|
5e9daa1206 | ||
|
|
a3c2956a4d | ||
|
|
10ecd92715 | ||
|
|
37d2d986ef | ||
|
|
0aadd6ebe2 | ||
|
|
c23ec9a4b8 | ||
|
|
22a37923f9 | ||
|
|
3fc506b438 | ||
|
|
e98dbd5069 | ||
|
|
2a469b27c5 | ||
|
|
0f3ef4559f | ||
|
|
a87ef0a0a6 | ||
|
|
a7a0a7f0db | ||
|
|
bc4622d610 | ||
|
|
8365603bf1 | ||
|
|
b1eabdba79 | ||
|
|
169e31e9ba | ||
|
|
66644d55a4 | ||
|
|
98314960cf | ||
|
|
b73e44874d | ||
|
|
6f45a44070 | ||
|
|
d9d11d685e | ||
|
|
5359267b5a | ||
|
|
a6662ab501 | ||
|
|
699a619c27 | ||
|
|
85ccbbf719 | ||
|
|
a396b33f3d | ||
|
|
6076f775c3 | ||
|
|
379fa88b4e | ||
|
|
9b24c507c5 | ||
|
|
98bd79f0be | ||
|
|
f09e28e782 | ||
|
|
b601b07586 | ||
|
|
73cea59691 | ||
|
|
e2993d47b6 | ||
|
|
2cd67e7cf8 | ||
|
|
c51da5a9d5 | ||
|
|
bcfce29610 | ||
|
|
a87d18fae3 | ||
|
|
bbd421445c | ||
|
|
f4e3d797dc | ||
|
|
bd65cbb8b8 | ||
|
|
7d1f81607a | ||
|
|
3b6cd0ea7f | ||
|
|
aff70d8519 | ||
|
|
8a74faa4f0 | ||
|
|
c1ac207809 | ||
|
|
e34e745c84 | ||
|
|
50dd119ab5 | ||
|
|
d0ef177d56 | ||
|
|
9b9c2e49b9 | ||
|
|
afeb307453 | ||
|
|
7568b1aedc | ||
|
|
742d8cee00 | ||
|
|
d52bef28ff | ||
|
|
b2f48421c7 | ||
|
|
22643bf9cc | ||
|
|
861ca63ea9 | ||
|
|
6e34356b6f | ||
|
|
a9b3025724 | ||
|
|
0cc019ef19 | ||
|
|
eb49b31aeb | ||
|
|
5ebbfd1c00 | ||
|
|
0df67b86f8 | ||
|
|
2df567372e | ||
|
|
0fb3c69e10 | ||
|
|
44900dbcbe | ||
|
|
89cd295f28 | ||
|
|
d06811d94d | ||
|
|
ced22ebb0a | ||
|
|
3c2ad26f1d | ||
|
|
9535e35ba7 | ||
|
|
298e87dce2 | ||
|
|
165ce61ded | ||
|
|
c70e3547d1 | ||
|
|
05b5953f35 | ||
|
|
9cba6e694a | ||
|
|
b2c73ec9d8 | ||
|
|
cd7620673b | ||
|
|
09bfb2b0f4 | ||
|
|
6dde7e9535 | ||
|
|
98e24072ce | ||
|
|
fdc67f8f0e | ||
|
|
f8acabcc86 | ||
|
|
5a0771b751 | ||
|
|
6a231f76e1 | ||
|
|
d203edbdae | ||
|
|
91a5aa8d4c | ||
|
|
40076dea36 | ||
|
|
2ab7228727 | ||
|
|
52e500a5fb | ||
|
|
44734867cc | ||
|
|
6565d05274 | ||
|
|
204758cbbb | ||
|
|
a60e7e13ca | ||
|
|
3596109249 | ||
|
|
3c703d9771 | ||
|
|
1e87dc4c52 | ||
|
|
ae61d50a6c | ||
|
|
63fb40dd65 | ||
|
|
afe2248bb8 | ||
|
|
b3b82ace3f | ||
|
|
903fef6791 | ||
|
|
542ad29cd9 | ||
|
|
d588e8d941 | ||
|
|
6b786084cf | ||
|
|
85da41be9a | ||
|
|
6e8a1cd6af | ||
|
|
0f28d5de11 | ||
|
|
0d39909d89 | ||
|
|
e4282a8e9d | ||
|
|
05a64308ac | ||
|
|
7b01bafd53 | ||
|
|
b521460335 | ||
|
|
249c8377bd | ||
|
|
58cdc9f29a | ||
|
|
065beb72e1 | ||
|
|
c00614f17d | ||
|
|
d99bc08e49 | ||
|
|
9e49b28ac3 | ||
|
|
d06b396aec | ||
|
|
65abef1282 | ||
|
|
b66d3ee8d4 | ||
|
|
597abdb20c | ||
|
|
174fa800be | ||
|
|
28670bc7fb | ||
|
|
a61e406c91 | ||
|
|
20f357cb12 | ||
|
|
5ba6b81fac | ||
|
|
e34bcd47d5 | ||
|
|
62ed8705e8 | ||
|
|
de18324798 | ||
|
|
a7a943c8dc | ||
|
|
6e975b9d66 | ||
|
|
9e53dc3d5f | ||
|
|
809e7d8701 | ||
|
|
0015c5704a | ||
|
|
a7ff1610eb | ||
|
|
22c402fc5e | ||
|
|
f3c19f9c02 | ||
|
|
33b4b9fbcb | ||
|
|
00396f2e1b | ||
|
|
8b71f99666 | ||
|
|
d00822a6c3 | ||
|
|
6e92d46a63 | ||
|
|
66ed926ea8 | ||
|
|
b7741ce2af | ||
|
|
1a17324d26 | ||
|
|
4044936481 | ||
|
|
1efe86421a | ||
|
|
34dd080f6c | ||
|
|
f4838afab0 | ||
|
|
b207eebe56 | ||
|
|
4f454ab438 | ||
|
|
1ecf416113 | ||
|
|
94670a03ff | ||
|
|
e92f165677 | ||
|
|
4a03137a25 | ||
|
|
7e6e1fb6de | ||
|
|
f477797823 | ||
|
|
125b6740a6 | ||
|
|
1618a11955 | ||
|
|
966d6e2383 | ||
|
|
2f33a135fc | ||
|
|
207ea492d5 | ||
|
|
250d5432a0 | ||
|
|
9768758ecc | ||
|
|
20852dbd12 | ||
|
|
2bc632474d | ||
|
|
78fd754d91 | ||
|
|
bfa0045f1d | ||
|
|
97e2d58750 | ||
|
|
ff668931ba | ||
|
|
1c0149afc9 | ||
|
|
12ee3ef497 | ||
|
|
ae2e38acac | ||
|
|
f25050bce8 | ||
|
|
830d500a68 | ||
|
|
960e5d9d29 | ||
|
|
75b9f27761 | ||
|
|
67af210f07 | ||
|
|
06cdcac4df | ||
|
|
10dc1d10ed | ||
|
|
43c65bf95b | ||
|
|
cb4ee2dcca | ||
|
|
bc64a96cc0 | ||
|
|
23dab16afc | ||
|
|
8755106fd2 | ||
|
|
b2c6c95dbd | ||
|
|
20d5fcd54d | ||
|
|
0d09233b28 | ||
|
|
1f2700de38 | ||
|
|
d7ebdfbf5a | ||
|
|
14b70a78ab | ||
|
|
dd41af8b8e | ||
|
|
5b19d61069 | ||
|
|
be3e028f5c | ||
|
|
d231436eb0 | ||
|
|
4c6276d3f6 | ||
|
|
583c00d2b7 | ||
|
|
060ded3915 | ||
|
|
8482a8746f | ||
|
|
dc12c0e770 | ||
|
|
6338e89507 | ||
|
|
0f97d29f6a | ||
|
|
686f746070 | ||
|
|
5363719643 | ||
|
|
607785dcd4 | ||
|
|
c14d39c456 | ||
|
|
2c9220090a | ||
|
|
b17ef8b6ff | ||
|
|
6ac96747cf | ||
|
|
92c8a13f96 | ||
|
|
6d07c335de | ||
|
|
eba1679761 | ||
|
|
05b05be0bd | ||
|
|
287861f5d7 | ||
|
|
4102c4a0ae | ||
|
|
d8fa0e33f1 | ||
|
|
97bc638f5f | ||
|
|
064c0ae425 | ||
|
|
ae7aa52177 | ||
|
|
6edda72d61 | ||
|
|
2f58f32bdd | ||
|
|
0b821db046 | ||
|
|
36472998ee | ||
|
|
c2e7325876 | ||
|
|
28a4a3849c | ||
|
|
6e9c934912 | ||
|
|
675ef0e629 | ||
|
|
484914b2dc | ||
|
|
ee85ef50f4 | ||
|
|
dcee5542c5 | ||
|
|
9b3ce4d849 | ||
|
|
5ab7e586f3 | ||
|
|
9f5d4ed52c | ||
|
|
c3ca734005 | ||
|
|
a158a488f2 | ||
|
|
6048cb917e | ||
|
|
81aac0d431 | ||
|
|
dfb50fbddc | ||
|
|
1f03e0a84b | ||
|
|
77e393ae48 | ||
|
|
0d8820bcab | ||
|
|
77bb5c2fcd | ||
|
|
475a4904a9 | ||
|
|
cf43b8ebda | ||
|
|
f34096af98 | ||
|
|
d60ff2a052 | ||
|
|
59d4953554 | ||
|
|
f76052b1d6 | ||
|
|
26e59b0953 | ||
|
|
9ee1164f08 | ||
|
|
cfc3823593 | ||
|
|
8407a414c5 | ||
|
|
a379604974 | ||
|
|
c01d80f7da | ||
|
|
7533dce0d2 | ||
|
|
9f1e97fd54 | ||
|
|
382a73310c | ||
|
|
5eeab7fd08 | ||
|
|
bc54e7cfba | ||
|
|
4502ffb6d2 | ||
|
|
b6f9ce824e | ||
|
|
d33081c1c7 | ||
|
|
76c08535d6 | ||
|
|
b55fef67e1 | ||
|
|
56798677d5 | ||
|
|
ff30b9c225 | ||
|
|
5c3293ec44 | ||
|
|
1b17605e0e | ||
|
|
ba4e4dcf56 | ||
|
|
b35d5d4779 | ||
|
|
124f31ebe1 | ||
|
|
173087ee19 | ||
|
|
8d7bad97de | ||
|
|
188fbfbb95 | ||
|
|
3498a54bdf | ||
|
|
18169c2355 | ||
|
|
87beb9442f | ||
|
|
e642d54929 | ||
|
|
59ce5d5e67 | ||
|
|
58d5237692 | ||
|
|
8d5bde6e60 | ||
|
|
bf740ddc93 | ||
|
|
fddbf35e8c | ||
|
|
a47fea02d1 | ||
|
|
250136cfdc | ||
|
|
597ad01e8f | ||
|
|
f7b44f2b0f | ||
|
|
5aab43ac93 | ||
|
|
2d278159ea | ||
|
|
da61462d79 | ||
|
|
2ab0912880 | ||
|
|
3914616222 | ||
|
|
a73b2703be | ||
|
|
49590f6d02 | ||
|
|
f4a0fcf5ba | ||
|
|
6ab803e682 | ||
|
|
0faa97b08c | ||
|
|
2ae488544b | ||
|
|
a7e2cfc878 | ||
|
|
da6db9c1b4 | ||
|
|
88b3e5cf34 | ||
|
|
7347f0ba10 | ||
|
|
4c55682552 | ||
|
|
324031aa2a | ||
|
|
1355c3d75c | ||
|
|
8533168155 | ||
|
|
51f6ec6e55 | ||
|
|
7e3f67c14d | ||
|
|
c51320f033 | ||
|
|
9c50a47abc | ||
|
|
473d273d18 | ||
|
|
f19b628655 | ||
|
|
fa74d4b27a | ||
|
|
cdb6655e37 | ||
|
|
4f19f7ebdf | ||
|
|
bf8838f943 | ||
|
|
1e1e9fabdc | ||
|
|
745972a717 | ||
|
|
6055776329 | ||
|
|
4074791f9a | ||
|
|
b1ab48e912 | ||
|
|
a71e2dd289 | ||
|
|
b8283acd0d | ||
|
|
bbdf1c756e | ||
|
|
283878879b | ||
|
|
b74ec98d68 | ||
|
|
3691db8e8e | ||
|
|
e25ccf6b25 | ||
|
|
ffebdb0c49 | ||
|
|
6accdbced5 | ||
|
|
2fcb94e1d7 | ||
|
|
6211ef974d | ||
|
|
0eacf7bb98 | ||
|
|
c9b7d650a8 | ||
|
|
a29f7d6533 | ||
|
|
72f8c626d7 | ||
|
|
f05ef5125d | ||
|
|
40b3d8e6fd | ||
|
|
a695bdc565 | ||
|
|
9700fabd9a | ||
|
|
4877db42f9 | ||
|
|
9b418fd63b | ||
|
|
b2eef0df11 | ||
|
|
34462829ff | ||
|
|
2afcbef8d0 | ||
|
|
695becbda0 | ||
|
|
5877d8215d | ||
|
|
48b357dfef | ||
|
|
b20cc7c0d9 | ||
|
|
0f43f02fad | ||
|
|
9b658cf0b8 | ||
|
|
ce705e12a8 | ||
|
|
28dede0d3e | ||
|
|
d66e61f845 | ||
|
|
b246575486 | ||
|
|
18dd205051 | ||
|
|
0e10fdaf36 | ||
|
|
7c82b4effb | ||
|
|
82684601b7 | ||
|
|
77ad21bd7a | ||
|
|
e6c8591bf8 | ||
|
|
e330be5d13 | ||
|
|
6a4cd9643a | ||
|
|
d98cb9a577 | ||
|
|
ac455527ef | ||
|
|
7e37345dea | ||
|
|
6e810179a7 | ||
|
|
7715aff953 | ||
|
|
63e6b9f026 | ||
|
|
b6f136fb71 | ||
|
|
de0327a00a | ||
|
|
e5f09ae4c9 | ||
|
|
f10d9b54d8 | ||
|
|
619d672e49 | ||
|
|
db519701bc | ||
|
|
e42aeb857f | ||
|
|
4f82495cfc | ||
|
|
311c36b7c0 | ||
|
|
002ce25d7e | ||
|
|
d9cf13d3fb | ||
|
|
ed5b1306b8 | ||
|
|
227fe86cf9 | ||
|
|
1905482b06 | ||
|
|
46ded4af0d | ||
|
|
6676ab82b4 | ||
|
|
1a60df6d98 | ||
|
|
5ef1b4ac9c | ||
|
|
17828ae755 | ||
|
|
d8ac4d6738 | ||
|
|
0a10cb509c | ||
|
|
7a3fd20dfa | ||
|
|
ab20e50dc1 | ||
|
|
f783ffef11 | ||
|
|
d62ecdc177 | ||
|
|
77cd7dda5f | ||
|
|
bd7099e97c | ||
|
|
b9457a35b9 | ||
|
|
53918ddddd | ||
|
|
84f0da0871 | ||
|
|
11c2e2e3bc | ||
|
|
622c8d1c18 | ||
|
|
10ffae7d4e | ||
|
|
15b48fd902 | ||
|
|
e2d7f2890d | ||
|
|
e01c485949 | ||
|
|
3672c84e8f | ||
|
|
55c5a07c8b | ||
|
|
a3cf32aefb | ||
|
|
c21bf30e91 | ||
|
|
1719547ce0 | ||
|
|
22186825a0 | ||
|
|
b9c83ad5cc | ||
|
|
1359689b23 | ||
|
|
7bad6ad077 | ||
|
|
b9097fa077 | ||
|
|
0b03806ccd | ||
|
|
db9c1279ac | ||
|
|
af510beb7b | ||
|
|
8cf0203b42 | ||
|
|
ea4a81c6ec | ||
|
|
63b53d2244 | ||
|
|
aba6b64074 | ||
|
|
324bfc733b | ||
|
|
fcfb3c9808 | ||
|
|
4ab77064ee | ||
|
|
ca2182d588 | ||
|
|
5ba410acd5 | ||
|
|
06382649c4 | ||
|
|
4f50e905af | ||
|
|
822cdab6ee | ||
|
|
8fad307c9a | ||
|
|
daa545f3db | ||
|
|
56892aea3c | ||
|
|
73e768def0 | ||
|
|
19da2267d6 | ||
|
|
3affec0f88 | ||
|
|
448c688629 | ||
|
|
fc2ab3f795 | ||
|
|
e520e695f9 | ||
|
|
b34f438430 | ||
|
|
72bfe15728 | ||
|
|
60198bc878 | ||
|
|
631c09badb | ||
|
|
2bf6eb6f0e | ||
|
|
1ee8b65ff7 | ||
|
|
d367750331 | ||
|
|
6d1bc5b1fd | ||
|
|
45771adef0 | ||
|
|
d91f613c28 | ||
|
|
988dd767d8 | ||
|
|
d715c175b8 | ||
|
|
a114605be1 | ||
|
|
f7a9e2ef89 | ||
|
|
2aa3133c52 | ||
|
|
2f15ea213d | ||
|
|
19a3f14190 | ||
|
|
fb716d300e | ||
|
|
1fe5095654 | ||
|
|
820d3f2413 | ||
|
|
34903fc951 | ||
|
|
7ec2e0c5cc | ||
|
|
846c346a86 | ||
|
|
f685ed6932 | ||
|
|
98b8ec5c89 | ||
|
|
0e20bf4afe | ||
|
|
fe588c08e2 | ||
|
|
3ee6ac605d | ||
|
|
535feb424c | ||
|
|
2cca696808 | ||
|
|
b5ea0ec7fa | ||
|
|
1e3d2595cf | ||
|
|
960b960726 | ||
|
|
cd29760836 | ||
|
|
27a2883f0a | ||
|
|
326bca2273 | ||
|
|
b32487fcb8 | ||
|
|
105bdff9ab | ||
|
|
6b767523a9 | ||
|
|
396050c051 | ||
|
|
c32d1877ff | ||
|
|
df78d9bf4c | ||
|
|
cc3bea3b2c | ||
|
|
87aa38b4e8 | ||
|
|
ee0215511a | ||
|
|
bd0056394e | ||
|
|
76ea7ab046 | ||
|
|
3d7ea1637f | ||
|
|
4b30905f9c | ||
|
|
bddb8431c5 | ||
|
|
61e02dd827 | ||
|
|
ff4eac8269 | ||
|
|
32eba77639 | ||
|
|
09eb82ca2e | ||
|
|
4d7ff5f6cc | ||
|
|
59dd53c025 | ||
|
|
c98d7561b8 | ||
|
|
5c8157b81f | ||
|
|
7f5ff1ab14 | ||
|
|
018c84b6af | ||
|
|
b95174727a | ||
|
|
0aec2359cf | ||
|
|
62bd5008fd | ||
|
|
89dd7beafe | ||
|
|
cecf3617af | ||
|
|
f4c52654a7 | ||
|
|
44b71460ee | ||
|
|
265fbc9f63 | ||
|
|
7c4b254f08 | ||
|
|
1bf01ca240 | ||
|
|
54ff63dbc7 | ||
|
|
61ddee0bba | ||
|
|
8174d236f6 | ||
|
|
b27d5607ac | ||
|
|
905f565766 | ||
|
|
b33c93290b | ||
|
|
5abb07fda2 | ||
|
|
b57069c55f | ||
|
|
5b1a4d3ff5 | ||
|
|
2b26f944d0 | ||
|
|
a15197f69d | ||
|
|
41f64b2e36 | ||
|
|
bec032c7dc | ||
|
|
0ffefddb86 | ||
|
|
09b154c997 | ||
|
|
d9f3b4f76e | ||
|
|
8ebb3ef804 | ||
|
|
b03682a81f | ||
|
|
5dd54be06c | ||
|
|
98c0b60207 | ||
|
|
10a0009532 | ||
|
|
5e203f0b27 | ||
|
|
46fc48cfd7 | ||
|
|
e8a17708d2 | ||
|
|
061eaa2a56 | ||
|
|
bc6e29b562 | ||
|
|
d8c1dcef29 | ||
|
|
ca281afba1 | ||
|
|
cde07a60d7 | ||
|
|
e31af0f43f | ||
|
|
15dd0f38e7 | ||
|
|
d93647e889 | ||
|
|
509d9a2fba | ||
|
|
879d05f1a6 | ||
|
|
ecf6bbfb66 | ||
|
|
bc42fda786 | ||
|
|
d3590372f3 | ||
|
|
88f55997fa | ||
|
|
0a1bc6716b | ||
|
|
559e546462 | ||
|
|
6c5775a2ed | ||
|
|
4858adbbe7 |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
|||||||
contact_links:
|
contact_links:
|
||||||
- name: ⚠️ Source issue
|
- name: ⚠️ Source issue
|
||||||
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
||||||
about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead
|
about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
@@ -60,7 +60,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
options:
|
options:
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||||
required: true
|
required: true
|
||||||
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
- label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
@@ -20,5 +20,5 @@ body:
|
|||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||||
options:
|
options:
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@
|
|||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
|
/.idea/deviceManager.xml
|
||||||
|
/.kotlin/
|
||||||
|
|||||||
1
.idea/.gitignore
generated
vendored
1
.idea/.gitignore
generated
vendored
@@ -2,3 +2,4 @@
|
|||||||
/shelf/
|
/shelf/
|
||||||
/workspace.xml
|
/workspace.xml
|
||||||
/migrations.xml
|
/migrations.xml
|
||||||
|
/runConfigurations.xml
|
||||||
|
|||||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -4,6 +4,7 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -1,8 +1,8 @@
|
|||||||
# Kotatsu
|
# Kotatsu
|
||||||
|
|
||||||
Kotatsu is a free and open source manga reader for Android.
|
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
||||||
|
|
||||||
   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
[](https://github.com/KotatsuApp/kotatsu-parsers)  [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
|
|
||||||
@@ -12,16 +12,15 @@ Kotatsu is a free and open source manga reader for Android.
|
|||||||
### Main Features
|
### Main Features
|
||||||
|
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||||
* Search manga by name and genres
|
* Search manga by name, genres, and more filters
|
||||||
* Reading history and bookmarks
|
* Reading history and bookmarks
|
||||||
* Favourites organized by user-defined categories
|
* Favorites organized by user-defined categories
|
||||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
||||||
* Tablet-optimized Material You UI
|
* Tablet-optimized Material You UI
|
||||||
* Standard and Webtoon-optimized reader
|
* Standard and Webtoon-optimized customizable reader
|
||||||
* Notifications about new chapters with updates feed
|
* Notifications about new chapters with updates feed
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
* Password/fingerprint protect access to the app
|
* Password/fingerprint-protected access to the app
|
||||||
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
@@ -53,5 +52,5 @@ install instructions.
|
|||||||
|
|
||||||
### DMCA disclaimer
|
### DMCA disclaimer
|
||||||
|
|
||||||
The developers of this application does not have any affiliation with the content available in the app.
|
The developers of this application do not have any affiliation with the content available in the app.
|
||||||
It is collecting from the sources freely available through any web browser.
|
It collects content from sources that are freely available through any web browser
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
buildToolsVersion = '34.0.0'
|
buildToolsVersion = '35.0.0'
|
||||||
namespace = 'org.koitharu.kotatsu'
|
namespace = 'org.koitharu.kotatsu'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 35
|
||||||
versionCode = 630
|
versionCode = 676
|
||||||
versionName = '6.8'
|
versionName = '7.6.3'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -48,14 +48,15 @@ android {
|
|||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
coreLibraryDesugaringEnabled true
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
|
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||||
@@ -63,7 +64,7 @@ android {
|
|||||||
}
|
}
|
||||||
lint {
|
lint {
|
||||||
abortOnError true
|
abortOnError true
|
||||||
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
|
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled'
|
||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.includeAndroidResources true
|
unitTests.includeAndroidResources true
|
||||||
@@ -81,36 +82,36 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
implementation('com.github.KotatsuApp:kotatsu-parsers:1ebb298cd7') {
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:639895f511') {
|
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23'
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
||||||
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||||
implementation 'androidx.core:core-ktx:1.12.0'
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation 'androidx.activity:activity-ktx:1.8.2'
|
implementation 'androidx.activity:activity-ktx:1.9.2'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
implementation 'androidx.fragment:fragment-ktx:1.8.4'
|
||||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
implementation 'androidx.collection:collection-ktx:1.4.4'
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
|
implementation 'androidx.viewpager2:viewpager2:1.1.0'
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||||
implementation 'com.google.android.material:material:1.12.0-alpha03'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
|
||||||
implementation 'androidx.webkit:webkit:1.10.0'
|
implementation 'androidx.webkit:webkit:1.11.0'
|
||||||
|
|
||||||
implementation 'androidx.work:work-runtime:2.9.0'
|
implementation 'androidx.work:work-runtime:2.9.1'
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.google.guava:guava:32.0.1-android') {
|
implementation('com.google.guava:guava:33.2.1-android') {
|
||||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
exclude group: 'com.google.guava', module: 'failureaccess'
|
||||||
exclude group: 'org.checkerframework', module: 'checker-qual'
|
exclude group: 'org.checkerframework', module: 'checker-qual'
|
||||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||||
@@ -121,46 +122,46 @@ dependencies {
|
|||||||
ksp 'androidx.room:room-compiler:2.6.1'
|
ksp 'androidx.room:room-compiler:2.6.1'
|
||||||
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
||||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||||
implementation 'com.squareup.okio:okio:3.9.0'
|
implementation 'com.squareup.okio:okio:3.9.1'
|
||||||
|
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||||
|
|
||||||
implementation 'com.google.dagger:hilt-android:2.51'
|
implementation 'com.google.dagger:hilt-android:2.52'
|
||||||
kapt 'com.google.dagger:hilt-compiler:2.51'
|
kapt 'com.google.dagger:hilt-compiler:2.52'
|
||||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
implementation 'androidx.hilt:hilt-work:1.2.0'
|
||||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
||||||
|
|
||||||
implementation 'io.coil-kt:coil-base:2.6.0'
|
implementation 'io.coil-kt:coil-base:2.7.0'
|
||||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:e04098de68'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
implementation 'io.noties.markwon:core:4.6.2'
|
implementation 'io.noties.markwon:core:4.6.2'
|
||||||
|
|
||||||
implementation 'ch.acra:acra-http:5.11.3'
|
implementation 'ch.acra:acra-http:5.11.4'
|
||||||
implementation 'ch.acra:acra-dialog:5.11.3'
|
implementation 'ch.acra:acra-dialog:5.11.4'
|
||||||
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
|
|
||||||
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
|
|
||||||
|
|
||||||
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
||||||
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8'
|
||||||
|
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.json:json:20240303'
|
testImplementation 'org.json:json:20240303'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
androidTestImplementation 'androidx.test:runner:1.6.1'
|
||||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
androidTestImplementation 'androidx.test:rules:1.6.1'
|
||||||
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
||||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
||||||
|
|
||||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||||
|
|
||||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51'
|
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52'
|
||||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51'
|
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52'
|
||||||
}
|
}
|
||||||
|
|||||||
5
app/proguard-rules.pro
vendored
5
app/proguard-rules.pro
vendored
@@ -14,6 +14,7 @@
|
|||||||
-dontwarn org.conscrypt.**
|
-dontwarn org.conscrypt.**
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
-dontwarn org.openjsse.**
|
-dontwarn org.openjsse.**
|
||||||
|
-dontwarn com.google.j2objc.annotations.**
|
||||||
|
|
||||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||||
@@ -21,3 +22,7 @@
|
|||||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
||||||
-keep class org.jsoup.parser.Tag
|
-keep class org.jsoup.parser.Tag
|
||||||
-keep class org.jsoup.internal.StringUtil
|
-keep class org.jsoup.internal.StringUtil
|
||||||
|
|
||||||
|
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
||||||
|
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
||||||
|
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.tracker.domain
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidRule
|
|
||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
|
||||||
import junit.framework.TestCase.*
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koitharu.kotatsu.SampleData
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidTest
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class TrackerTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
var hiltRule = HiltAndroidRule(this)
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var repository: TrackingRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var dataRepository: MangaDataRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var tracker: Tracker
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
hiltRule.inject()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noUpdates() = runTest {
|
|
||||||
val manga = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(manga.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(manga, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
|
||||||
tracker.checkUpdates(manga, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hasUpdates() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun badIds() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaBad = loadManga("bad_ids.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun badIds2() = runTest {
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaBad = loadManga("bad_ids.json")
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
tracker.deleteTrack(mangaFirst.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fullReset() = runTest {
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
val mangaEmpty = loadManga("empty.json")
|
|
||||||
tracker.deleteTrack(mangaFull.id)
|
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun syncWithHistory() = runTest {
|
|
||||||
val mangaFull = loadManga("full.json")
|
|
||||||
val mangaFirst = loadManga("first_chapters.json")
|
|
||||||
tracker.deleteTrack(mangaFull.id)
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
|
||||||
assertFalse(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assertEquals(3, newChapters.size)
|
|
||||||
}
|
|
||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
|
||||||
repository.syncWithHistory(mangaFull, chapter.id)
|
|
||||||
|
|
||||||
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
|
||||||
repository.syncWithHistory(mangaFull, chapter.id)
|
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
|
|
||||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
|
||||||
assertTrue(isValid)
|
|
||||||
assert(newChapters.isEmpty())
|
|
||||||
}
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadManga(name: String): Manga {
|
|
||||||
val manga = SampleData.loadAsset("manga/$name", Manga::class)
|
|
||||||
dataRepository.storeManga(manga)
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
import org.koitharu.kotatsu.core.BaseApp
|
import org.koitharu.kotatsu.core.BaseApp
|
||||||
@@ -8,38 +9,65 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
|||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
class KotatsuApp : BaseApp() {
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
override fun attachBaseContext(base: Context) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
enableStrictMode()
|
enableStrictMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableStrictMode() {
|
private fun enableStrictMode() {
|
||||||
|
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
StrictModeNotifier(this)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy.Builder()
|
StrictMode.ThreadPolicy.Builder().apply {
|
||||||
.detectAll()
|
detectNetwork()
|
||||||
.penaltyLog()
|
detectDiskWrites()
|
||||||
.build(),
|
detectCustomSlowCalls()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
||||||
|
penaltyLog()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
|
penaltyListener(notifier.executor, notifier)
|
||||||
|
}
|
||||||
|
}.build(),
|
||||||
)
|
)
|
||||||
StrictMode.setVmPolicy(
|
StrictMode.setVmPolicy(
|
||||||
StrictMode.VmPolicy.Builder()
|
StrictMode.VmPolicy.Builder().apply {
|
||||||
.detectAll()
|
detectActivityLeaks()
|
||||||
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
detectLeakedSqlLiteObjects()
|
||||||
.setClassInstanceLimit(PagesCache::class.java, 1)
|
detectLeakedClosableObjects()
|
||||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
detectLeakedRegistrationObjects()
|
||||||
.setClassInstanceLimit(PageLoader::class.java, 1)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
||||||
.penaltyLog()
|
detectFileUriExposure()
|
||||||
.build(),
|
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||||
|
setClassInstanceLimit(PagesCache::class.java, 1)
|
||||||
|
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||||
|
setClassInstanceLimit(PageLoader::class.java, 1)
|
||||||
|
setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
||||||
|
penaltyLog()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
|
penaltyListener(notifier.executor, notifier)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
)
|
)
|
||||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
||||||
.penaltyDeath()
|
detectWrongFragmentContainer()
|
||||||
.detectFragmentReuse()
|
detectFragmentTagUsage()
|
||||||
.detectWrongFragmentContainer()
|
detectRetainInstanceUsage()
|
||||||
.detectRetainInstanceUsage()
|
detectSetUserVisibleHint()
|
||||||
.detectSetUserVisibleHint()
|
detectWrongNestedHierarchy()
|
||||||
.detectFragmentTagUsage()
|
detectFragmentReuse()
|
||||||
.build()
|
penaltyLog()
|
||||||
|
if (notifier != null) {
|
||||||
|
penaltyListener(notifier)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.Notification.BigTextStyle
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.StrictMode
|
||||||
|
import android.os.strictmode.Violation
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
|
class StrictModeNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
|
||||||
|
|
||||||
|
val executor = Dispatchers.Default.asExecutor()
|
||||||
|
|
||||||
|
private val notificationManager by lazy {
|
||||||
|
val nm = checkNotNull(context.getSystemService<NotificationManager>())
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.strict_mode),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
nm
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVmViolation(v: Violation) = showNotification(v)
|
||||||
|
|
||||||
|
override fun onThreadViolation(v: Violation) = showNotification(v)
|
||||||
|
|
||||||
|
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||||
|
|
||||||
|
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.setContentTitle(context.getString(R.string.strict_mode))
|
||||||
|
.setContentText(violation.message)
|
||||||
|
.setStyle(
|
||||||
|
BigTextStyle()
|
||||||
|
.setBigContentTitle(context.getString(R.string.strict_mode))
|
||||||
|
.setSummaryText(violation.message)
|
||||||
|
.bigText(violation.stackTraceToString()),
|
||||||
|
).setShowWhen(true)
|
||||||
|
.setContentIntent(ErrorReporterReceiver.getPendingIntent(context, violation))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setGroup(CHANNEL_ID)
|
||||||
|
.build()
|
||||||
|
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "strict_mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
|
import android.os.Looper
|
||||||
|
|
||||||
|
fun Throwable.printStackTraceDebug() = printStackTrace()
|
||||||
|
|
||||||
|
fun assertNotInMainThread() = check(Looper.myLooper() != Looper.getMainLooper()) {
|
||||||
|
"Calling this from the main thread is prohibited"
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
|
||||||
|
|
||||||
fun Throwable.printStackTraceDebug() = printStackTrace()
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
|
import leakcanary.LeakCanary
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.workinspector.WorkInspector
|
||||||
|
|
||||||
|
class SettingsMenuProvider(
|
||||||
|
private val context: Context,
|
||||||
|
) : MenuProvider {
|
||||||
|
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||||
|
R.id.action_leaks -> {
|
||||||
|
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.action_works -> {
|
||||||
|
context.startActivity(WorkInspector.getIntent(context))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,9 @@
|
|||||||
android:title="@string/leak_canary_display_activity_label"
|
android:title="@string/leak_canary_display_activity_label"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
</menu>
|
<item
|
||||||
|
android:id="@id/action_works"
|
||||||
|
android:title="@string/wi_lib_name"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
</menu>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
|
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
|
||||||
|
<bool name="wi_launcher_icon_enabled" tools:node="replace">false</bool>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||||
</resources>
|
<string name="strict_mode">Strict mode</string>
|
||||||
|
</resources>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
31
app/src/main/assets/isrgrootx1.pem
Normal file
31
app/src/main/assets/isrgrootx1.pem
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||||
|
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||||
|
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||||
|
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||||
|
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||||
|
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||||
|
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||||
|
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||||
|
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||||
|
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||||
|
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||||
|
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||||
|
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||||
|
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||||
|
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||||
|
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||||
|
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||||
|
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||||
|
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||||
|
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||||
|
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||||
|
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||||
|
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||||
|
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||||
|
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||||
|
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||||
|
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||||
|
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||||
|
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -12,19 +12,23 @@ import org.koitharu.kotatsu.core.util.ext.almostEquals
|
|||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val MAX_PARALLELISM = 4
|
private const val MAX_PARALLELISM = 4
|
||||||
private const val MATCH_THRESHOLD = 0.2f
|
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
|
||||||
|
|
||||||
class AlternativesUseCase @Inject constructor(
|
class AlternativesUseCase @Inject constructor(
|
||||||
private val sourcesRepository: MangaSourcesRepository,
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
|
||||||
|
|
||||||
|
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
|
||||||
val sources = getSources(manga.source)
|
val sources = getSources(manga.source)
|
||||||
if (sources.isEmpty()) {
|
if (sources.isEmpty()) {
|
||||||
return emptyFlow()
|
return emptyFlow()
|
||||||
@@ -33,17 +37,17 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
return channelFlow {
|
return channelFlow {
|
||||||
for (source in sources) {
|
for (source in sources) {
|
||||||
val repository = mangaRepositoryFactory.create(source)
|
val repository = mangaRepositoryFactory.create(source)
|
||||||
if (!repository.isSearchSupported) {
|
if (!repository.filterCapabilities.isSearchSupported) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
launch {
|
launch {
|
||||||
val list = runCatchingCancellable {
|
val list = runCatchingCancellable {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
for (item in list) {
|
for (item in list) {
|
||||||
if (item.matches(manga)) {
|
if (item.matches(manga, matchThreshold)) {
|
||||||
send(item)
|
send(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,29 +61,31 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
||||||
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
|
val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
|
||||||
result.addAll(sourcesRepository.getEnabledSources())
|
result.addAll(sourcesRepository.getEnabledSources())
|
||||||
result.sortByDescending { it.priority(ref) }
|
result.sortByDescending { it.priority(ref) }
|
||||||
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Manga.matches(ref: Manga): Boolean {
|
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
|
||||||
return matchesTitles(title, ref.title) ||
|
return matchesTitles(title, ref.title, threshold) ||
|
||||||
matchesTitles(title, ref.altTitle) ||
|
matchesTitles(title, ref.altTitle, threshold) ||
|
||||||
matchesTitles(altTitle, ref.title) ||
|
matchesTitles(altTitle, ref.title, threshold) ||
|
||||||
matchesTitles(altTitle, ref.altTitle)
|
matchesTitles(altTitle, ref.altTitle, threshold)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun matchesTitles(a: String?, b: String?): Boolean {
|
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
||||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||||
var res = 0
|
var res = 0
|
||||||
if (locale == ref.locale) res += 2
|
if (this is MangaParserSource && ref is MangaParserSource) {
|
||||||
if (contentType == ref.contentType) res++
|
if (locale == ref.locale) res += 2
|
||||||
|
if (contentType == ref.contentType) res++
|
||||||
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.lastOrNull
|
||||||
|
import kotlinx.coroutines.flow.runningFold
|
||||||
|
import kotlinx.coroutines.flow.transformWhile
|
||||||
|
import kotlinx.coroutines.flow.withIndex
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
class AutoFixUseCase @Inject constructor(
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
|
private val migrateUseCase: MigrateUseCase,
|
||||||
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
||||||
|
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
|
||||||
|
.getDetailsSafe()
|
||||||
|
if (seed.isHealthy()) {
|
||||||
|
return seed to null // no fix required
|
||||||
|
}
|
||||||
|
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
|
||||||
|
.filter { it.isHealthy() }
|
||||||
|
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||||
|
if (best == null || best < candidate) {
|
||||||
|
candidate
|
||||||
|
} else {
|
||||||
|
best
|
||||||
|
}
|
||||||
|
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
|
||||||
|
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
|
||||||
|
return seed to replacement
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
|
||||||
|
val repo = mangaRepositoryFactory.create(source)
|
||||||
|
val details = if (this.chapters != null) this else repo.getDetails(this)
|
||||||
|
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
|
||||||
|
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
|
||||||
|
pageUrl.toHttpUrlOrNull() != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(source).getDetails(this)
|
||||||
|
}.getOrDefault(this)
|
||||||
|
|
||||||
|
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
|
||||||
|
private suspend fun <T> Flow<T>.selectLastWithTimeout(
|
||||||
|
minCount: Int,
|
||||||
|
timeout: Long,
|
||||||
|
timeUnit: TimeUnit
|
||||||
|
): T? = channelFlow<T?> {
|
||||||
|
var lastValue: T? = null
|
||||||
|
launch {
|
||||||
|
delay(timeUnit.toMillis(timeout))
|
||||||
|
close(InternalTimeoutException(lastValue))
|
||||||
|
}
|
||||||
|
withIndex().transformWhile { (index, value) ->
|
||||||
|
lastValue = value
|
||||||
|
emit(value)
|
||||||
|
index < minCount && !isClosedForSend
|
||||||
|
}.collect {
|
||||||
|
send(it)
|
||||||
|
}
|
||||||
|
}.catch { e ->
|
||||||
|
if (e is InternalTimeoutException) {
|
||||||
|
emit(e.value as T?)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}.lastOrNull()
|
||||||
|
|
||||||
|
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
|
||||||
|
|
||||||
|
private class InternalTimeoutException(val value: Any?) : CancellationException()
|
||||||
|
}
|
||||||
@@ -5,63 +5,115 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
|||||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
|
|
||||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
|
||||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
||||||
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MigrateUseCase @Inject constructor(
|
class MigrateUseCase
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
private val database: MangaDatabase,
|
private val database: MangaDatabase,
|
||||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||||
private val useCase: DetailsLoadUseCase
|
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||||
) {
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
oldManga: Manga,
|
||||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
newManga: Manga,
|
||||||
runCatchingCancellable {
|
) {
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
val oldDetails =
|
||||||
}.getOrDefault(oldManga)
|
if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
} else {
|
runCatchingCancellable {
|
||||||
oldManga
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
}
|
}.getOrDefault(oldManga)
|
||||||
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
} else {
|
||||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
oldManga
|
||||||
} else {
|
}
|
||||||
newManga
|
val newDetails =
|
||||||
}
|
if (newManga.chapters.isNullOrEmpty()) {
|
||||||
|
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||||
|
} else {
|
||||||
|
newManga
|
||||||
|
}
|
||||||
mangaDataRepository.storeManga(newDetails)
|
mangaDataRepository.storeManga(newDetails)
|
||||||
database.withTransaction {
|
database.withTransaction {
|
||||||
// replace favorites
|
// replace favorites
|
||||||
val favoritesDao = database.getFavouritesDao()
|
val favoritesDao = database.getFavouritesDao()
|
||||||
val oldFavourite = favoritesDao.find(oldDetails.id)
|
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||||
if (oldFavourite != null) {
|
if (oldFavourites.isNotEmpty()) {
|
||||||
favoritesDao.delete(oldManga.id)
|
favoritesDao.delete(oldManga.id)
|
||||||
for (f in oldFavourite.categories) {
|
for (f in oldFavourites) {
|
||||||
val e = FavouriteEntity(
|
val e =
|
||||||
mangaId = newManga.id,
|
f.copy(
|
||||||
categoryId = f.categoryId.toLong(),
|
mangaId = newManga.id,
|
||||||
sortKey = f.sortKey,
|
)
|
||||||
createdAt = f.createdAt,
|
|
||||||
deletedAt = 0,
|
|
||||||
)
|
|
||||||
favoritesDao.upsert(e)
|
favoritesDao.upsert(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// replace history
|
// replace history
|
||||||
val historyDao = database.getHistoryDao()
|
val historyDao = database.getHistoryDao()
|
||||||
val oldHistory = historyDao.find(oldDetails.id)
|
val oldHistory = historyDao.find(oldDetails.id)
|
||||||
if (oldHistory != null) {
|
val newHistory =
|
||||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
if (oldHistory != null) {
|
||||||
historyDao.delete(oldDetails.id)
|
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||||
historyDao.upsert(newHistory)
|
historyDao.delete(oldDetails.id)
|
||||||
|
historyDao.upsert(newHistory)
|
||||||
|
newHistory
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
// track
|
||||||
|
val tracksDao = database.getTracksDao()
|
||||||
|
val oldTrack = tracksDao.find(oldDetails.id)
|
||||||
|
if (oldTrack != null) {
|
||||||
|
val lastChapter = newDetails.chapters?.lastOrNull()
|
||||||
|
val newTrack =
|
||||||
|
TrackEntity(
|
||||||
|
mangaId = newDetails.id,
|
||||||
|
lastChapterId = lastChapter?.id ?: 0L,
|
||||||
|
newChapters = 0,
|
||||||
|
lastCheckTime = System.currentTimeMillis(),
|
||||||
|
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||||
|
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||||
|
lastError = null,
|
||||||
|
)
|
||||||
|
tracksDao.delete(oldDetails.id)
|
||||||
|
tracksDao.upsert(newTrack)
|
||||||
|
}
|
||||||
|
// scrobbling
|
||||||
|
for (scrobbler in scrobblers) {
|
||||||
|
if (!scrobbler.isEnabled) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
|
||||||
|
scrobbler.unregisterScrobbling(oldDetails.id)
|
||||||
|
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
|
||||||
|
scrobbler.updateScrobblingInfo(
|
||||||
|
mangaId = newDetails.id,
|
||||||
|
rating = prevInfo.rating,
|
||||||
|
status =
|
||||||
|
prevInfo.status ?: when {
|
||||||
|
newHistory == null -> ScrobblingStatus.PLANNED
|
||||||
|
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||||
|
else -> ScrobblingStatus.READING
|
||||||
|
},
|
||||||
|
comment = prevInfo.comment,
|
||||||
|
)
|
||||||
|
if (newHistory != null) {
|
||||||
|
scrobbler.scrobble(
|
||||||
|
manga = newDetails,
|
||||||
|
chapterId = newHistory.chapterId,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
progressUpdateUseCase(newManga)
|
progressUpdateUseCase(newManga)
|
||||||
@@ -75,48 +127,53 @@ class MigrateUseCase @Inject constructor(
|
|||||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||||
val branch = newManga.getPreferredBranch(null)
|
val branch = newManga.getPreferredBranch(null)
|
||||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||||
val currentChapter = if (history.percent in 0f..1f) {
|
val currentChapter =
|
||||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
if (history.percent in 0f..1f) {
|
||||||
} else {
|
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||||
chapters.first()
|
} else {
|
||||||
}
|
chapters.first()
|
||||||
|
}
|
||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = history.updatedAt,
|
||||||
chapterId = currentChapter.id,
|
chapterId = currentChapter.id,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
percent = history.percent,
|
percent = history.percent,
|
||||||
deletedAt = 0,
|
deletedAt = 0,
|
||||||
chaptersCount = chapters.size,
|
chaptersCount = chapters.count { it.branch == currentChapter.branch },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
index = if (history.percent in 0f..1f) {
|
index =
|
||||||
(oldChapters.lastIndex * history.percent).toInt()
|
if (history.percent in 0f..1f) {
|
||||||
} else {
|
(oldChapters.lastIndex * history.percent).toInt()
|
||||||
0
|
} else {
|
||||||
}
|
0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||||
val newBranch = if (newChapters.containsKey(branch)) {
|
val newBranch =
|
||||||
branch
|
if (newChapters.containsKey(branch)) {
|
||||||
} else {
|
branch
|
||||||
newManga.getPreferredBranch(null)
|
} else {
|
||||||
}
|
newManga.getPreferredBranch(null)
|
||||||
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
}
|
||||||
val oldChapter = oldChapters[index]
|
val newChapterId =
|
||||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
checkNotNull(newChapters[newBranch])
|
||||||
}.id
|
.let {
|
||||||
|
val oldChapter = oldChapters[index]
|
||||||
|
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||||
|
}.id
|
||||||
|
|
||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = history.updatedAt,
|
||||||
chapterId = newChapterId,
|
chapterId = newChapterId,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
@@ -126,11 +183,13 @@ class MigrateUseCase @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
private fun List<MangaChapter>.findByNumber(
|
||||||
return if (number <= 0f) {
|
volume: Int,
|
||||||
|
number: Float,
|
||||||
|
): MangaChapter? =
|
||||||
|
if (number <= 0f) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
firstOrNull { it.volume == volume && it.number == number }
|
firstOrNull { it.volume == volume && it.number == number }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,17 @@ import androidx.core.text.inSpans
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.transform.CircleCropTransformation
|
import coil.transform.RoundedCornersTransformation
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
@@ -60,9 +62,9 @@ fun alternativeAD(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||||
binding.chipSource.also { chip ->
|
binding.chipSource.also { chip ->
|
||||||
chip.text = item.manga.source.title
|
chip.text = item.manga.source.getTitle(chip.context)
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(item.manga.source.faviconUri())
|
.data(item.manga.source.faviconUri())
|
||||||
.lifecycle(lifecycleOwner)
|
.lifecycle(lifecycleOwner)
|
||||||
@@ -73,15 +75,13 @@ fun alternativeAD(
|
|||||||
.fallback(R.drawable.ic_web)
|
.fallback(R.drawable.ic_web)
|
||||||
.error(R.drawable.ic_web)
|
.error(R.drawable.ic_web)
|
||||||
.source(item.manga.source)
|
.source(item.manga.source)
|
||||||
.transformations(CircleCropTransformation())
|
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
}
|
}
|
||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||||
size(CoverSizeResolver(binding.imageViewCover))
|
size(CoverSizeResolver(binding.imageViewCover))
|
||||||
placeholder(R.drawable.ic_placeholder)
|
defaultPlaceholders(context)
|
||||||
fallback(R.drawable.ic_placeholder)
|
|
||||||
error(R.drawable.ic_error_placeholder)
|
|
||||||
transformations(TrimTransformation())
|
transformations(TrimTransformation())
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
tag(item.manga)
|
tag(item.manga)
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ import androidx.activity.viewModels
|
|||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||||
@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -81,29 +82,37 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
|
|
||||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||||
when (view.id) {
|
when (view.id) {
|
||||||
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
R.id.chip_source -> startActivity(
|
||||||
|
MangaListActivity.newIntent(
|
||||||
|
this,
|
||||||
|
item.manga.source,
|
||||||
|
MangaListFilter(query = viewModel.manga.title),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
R.id.button_migrate -> confirmMigration(item.manga)
|
R.id.button_migrate -> confirmMigration(item.manga)
|
||||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmMigration(target: Manga) {
|
private fun confirmMigration(target: Manga) {
|
||||||
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
buildAlertDialog(this, isCentered = true) {
|
||||||
.setIcon(R.drawable.ic_replace)
|
setIcon(R.drawable.ic_replace)
|
||||||
.setTitle(R.string.manga_migration)
|
setTitle(R.string.manga_migration)
|
||||||
.setMessage(
|
setMessage(
|
||||||
getString(
|
getString(
|
||||||
R.string.migrate_confirmation,
|
R.string.migrate_confirmation,
|
||||||
viewModel.manga.title,
|
viewModel.manga.title,
|
||||||
viewModel.manga.source.title,
|
viewModel.manga.source.getTitle(context),
|
||||||
target.title,
|
target.title,
|
||||||
target.source.title,
|
target.source.getTitle(context),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
.setPositiveButton(R.string.migrate) { _, _ ->
|
setPositiveButton(R.string.migrate) { _, _ ->
|
||||||
viewModel.migrate(target)
|
viewModel.migrate(target)
|
||||||
}.show()
|
}
|
||||||
|
}.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import org.koitharu.kotatsu.core.model.chaptersCount
|
|||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
@@ -34,7 +36,8 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val alternativesUseCase: AlternativesUseCase,
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
private val migrateUseCase: MigrateUseCase,
|
private val migrateUseCase: MigrateUseCase,
|
||||||
private val extraProvider: ListExtraProvider,
|
private val historyRepository: HistoryRepository,
|
||||||
|
private val settings: AppSettings,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||||
@@ -53,7 +56,7 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
.map {
|
.map {
|
||||||
MangaAlternativeModel(
|
MangaAlternativeModel(
|
||||||
manga = it,
|
manga = it,
|
||||||
progress = extraProvider.getProgress(it.id),
|
progress = getProgress(it.id),
|
||||||
referenceChapters = refCount,
|
referenceChapters = refCount,
|
||||||
)
|
)
|
||||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||||
@@ -86,13 +89,7 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
|
||||||
return list.map {
|
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
|
||||||
MangaAlternativeModel(
|
|
||||||
manga = it,
|
|
||||||
progress = extraProvider.getProgress(it.id),
|
|
||||||
referenceChapters = refCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AutoFixService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var autoFixUseCase: AutoFixUseCase
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||||
|
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||||
|
startForeground(startId)
|
||||||
|
try {
|
||||||
|
for (mangaId in ids) {
|
||||||
|
val result = runCatchingCancellable {
|
||||||
|
autoFixUseCase.invoke(mangaId)
|
||||||
|
}
|
||||||
|
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
val notification = buildNotification(result)
|
||||||
|
notificationManager.notify(TAG, startId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(startId: Int, error: Throwable) {
|
||||||
|
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||||
|
notificationManager.notify(TAG, startId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
private fun startForeground(startId: Int) {
|
||||||
|
val title = applicationContext.getString(R.string.fixing_manga)
|
||||||
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||||
|
.setName(title)
|
||||||
|
.setShowBadge(false)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_auto_fix)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
materialR.drawable.material_ic_clear_black_24dp,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(startId),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
this,
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
result.onSuccess { (seed, replacement) ->
|
||||||
|
if (replacement != null) {
|
||||||
|
notification.setLargeIcon(
|
||||||
|
coil.execute(
|
||||||
|
ImageRequest.Builder(applicationContext)
|
||||||
|
.data(replacement.coverUrl)
|
||||||
|
.tag(replacement.source)
|
||||||
|
.build(),
|
||||||
|
).toBitmapOrNull(),
|
||||||
|
)
|
||||||
|
notification.setSubText(replacement.title)
|
||||||
|
val intent = DetailsActivity.newIntent(applicationContext, replacement)
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
replacement.id.toInt(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).setVisibility(
|
||||||
|
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
|
||||||
|
)
|
||||||
|
notification
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.fixed))
|
||||||
|
.setContentText(
|
||||||
|
applicationContext.getString(
|
||||||
|
R.string.manga_replaced,
|
||||||
|
seed.title,
|
||||||
|
seed.source.getTitle(applicationContext),
|
||||||
|
replacement.title,
|
||||||
|
replacement.source.getTitle(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
} else {
|
||||||
|
notification
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
||||||
|
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
notification
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||||
|
.setContentText(
|
||||||
|
if (error is AutoFixUseCase.NoAlternativesException) {
|
||||||
|
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||||
|
} else {
|
||||||
|
error.getDisplayMessage(applicationContext.resources)
|
||||||
|
},
|
||||||
|
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
||||||
|
notification.addAction(
|
||||||
|
R.drawable.ic_alert_outline,
|
||||||
|
applicationContext.getString(R.string.report),
|
||||||
|
reportIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return notification.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val DATA_IDS = "ids"
|
||||||
|
private const val TAG = "auto_fix"
|
||||||
|
private const val CHANNEL_ID = "auto_fix"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 38
|
||||||
|
|
||||||
|
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
|
||||||
|
val intent = Intent(context, AutoFixService::class.java)
|
||||||
|
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaAlternativeModel(
|
data class MangaAlternativeModel(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val progress: Float,
|
val progress: ReadingProgress?,
|
||||||
private val referenceChapters: Int,
|
private val referenceChapters: Int,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
|||||||
@Dao
|
@Dao
|
||||||
abstract class BookmarksDao {
|
abstract class BookmarksDao {
|
||||||
|
|
||||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
|
||||||
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
|
|
||||||
|
|
||||||
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
|
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
|
||||||
abstract suspend fun find(pageId: Long): BookmarkEntity?
|
abstract suspend fun find(pageId: Long): BookmarkEntity?
|
||||||
|
|
||||||
@@ -42,9 +39,6 @@ abstract class BookmarksDao {
|
|||||||
@Delete
|
@Delete
|
||||||
abstract suspend fun delete(entity: BookmarkEntity)
|
abstract suspend fun delete(entity: BookmarkEntity)
|
||||||
|
|
||||||
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
|
||||||
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
|
|
||||||
|
|
||||||
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
|
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
|
||||||
abstract suspend fun delete(pageId: Long): Int
|
abstract suspend fun delete(pageId: Long): Int
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ data class Bookmark(
|
|||||||
val percent: Float,
|
val percent: Float,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
val directImageUrl: String?
|
|
||||||
get() = if (isImageUrlDirect()) imageUrl else null
|
|
||||||
|
|
||||||
val imageLoadData: Any
|
val imageLoadData: Any
|
||||||
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
|||||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class BookmarksActivity :
|
class AllBookmarksActivity :
|
||||||
BaseActivity<ActivityContainerBinding>(),
|
BaseActivity<ActivityContainerBinding>(),
|
||||||
AppBarOwner,
|
AppBarOwner,
|
||||||
SnackbarOwner {
|
SnackbarOwner {
|
||||||
@@ -35,7 +35,7 @@ class BookmarksActivity :
|
|||||||
if (fm.findFragmentById(R.id.container) == null) {
|
if (fm.findFragmentById(R.id.container) == null) {
|
||||||
fm.commit {
|
fm.commit {
|
||||||
setReorderingAllowed(true)
|
setReorderingAllowed(true)
|
||||||
replace(R.id.container, BookmarksFragment::class.java, null)
|
replace(R.id.container, AllBookmarksFragment::class.java, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,6 @@ class BookmarksActivity :
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun newIntent(context: Context) = Intent(context, BookmarksActivity::class.java)
|
fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.bookmarks.ui
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -17,7 +18,7 @@ import coil.ImageLoader
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter
|
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||||
@@ -25,11 +26,12 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
|||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||||
@@ -41,11 +43,11 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class BookmarksFragment :
|
class AllBookmarksFragment :
|
||||||
BaseFragment<FragmentListSimpleBinding>(),
|
BaseFragment<FragmentListSimpleBinding>(),
|
||||||
ListStateHolderListener,
|
ListStateHolderListener,
|
||||||
OnListItemClickListener<Bookmark>,
|
OnListItemClickListener<Bookmark>,
|
||||||
ListSelectionController.Callback2,
|
ListSelectionController.Callback,
|
||||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
FastScroller.FastScrollListener, ListHeaderClickListener {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -54,7 +56,7 @@ class BookmarksFragment :
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var settings: AppSettings
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
private val viewModel by viewModels<BookmarksViewModel>()
|
private val viewModel by viewModels<AllBookmarksViewModel>()
|
||||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
private var bookmarksAdapter: BookmarksAdapter? = null
|
||||||
private var selectionController: ListSelectionController? = null
|
private var selectionController: ListSelectionController? = null
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ class BookmarksFragment :
|
|||||||
) {
|
) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
selectionController = ListSelectionController(
|
selectionController = ListSelectionController(
|
||||||
activity = requireActivity(),
|
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
|
||||||
decoration = BookmarksSelectionDecoration(binding.root.context),
|
decoration = BookmarksSelectionDecoration(binding.root.context),
|
||||||
registryOwner = this,
|
registryOwner = this,
|
||||||
callback = this,
|
callback = this,
|
||||||
@@ -85,7 +87,7 @@ class BookmarksFragment :
|
|||||||
val spanSizeLookup = SpanSizeLookup()
|
val spanSizeLookup = SpanSizeLookup()
|
||||||
with(binding.recyclerView) {
|
with(binding.recyclerView) {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
val spanResolver = MangaListSpanResolver(resources)
|
val spanResolver = GridSpanResolver(resources)
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||||
adapter = bookmarksAdapter
|
adapter = bookmarksAdapter
|
||||||
addOnLayoutChangeListener(spanResolver)
|
addOnLayoutChangeListener(spanResolver)
|
||||||
@@ -100,7 +102,7 @@ class BookmarksFragment :
|
|||||||
}
|
}
|
||||||
viewModel.onError.observeEvent(
|
viewModel.onError.observeEvent(
|
||||||
viewLifecycleOwner,
|
viewLifecycleOwner,
|
||||||
SnackbarErrorObserver(binding.recyclerView, this)
|
SnackbarErrorObserver(binding.recyclerView, this),
|
||||||
)
|
)
|
||||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||||
}
|
}
|
||||||
@@ -128,7 +130,11 @@ class BookmarksFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||||
return selectionController?.onItemLongClick(item.pageId) ?: false
|
return selectionController?.onItemLongClick(view, item.pageId) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
||||||
|
return selectionController?.onItemContextClick(view, item.pageId) ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRetryClick(error: Throwable) = Unit
|
override fun onRetryClick(error: Throwable) = Unit
|
||||||
@@ -147,23 +153,23 @@ class BookmarksFragment :
|
|||||||
|
|
||||||
override fun onCreateActionMode(
|
override fun onCreateActionMode(
|
||||||
controller: ListSelectionController,
|
controller: ListSelectionController,
|
||||||
mode: ActionMode,
|
menuInflater: MenuInflater,
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActionItemClicked(
|
override fun onActionItemClicked(
|
||||||
controller: ListSelectionController,
|
controller: ListSelectionController,
|
||||||
mode: ActionMode,
|
mode: ActionMode?,
|
||||||
item: MenuItem,
|
item: MenuItem,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_remove -> {
|
R.id.action_remove -> {
|
||||||
val ids = selectionController?.snapshot() ?: return false
|
val ids = selectionController?.snapshot() ?: return false
|
||||||
viewModel.removeBookmarks(ids)
|
viewModel.removeBookmarks(ids)
|
||||||
mode.finish()
|
mode?.finish()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,11 +212,12 @@ class BookmarksFragment :
|
|||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@Deprecated(
|
@Deprecated(
|
||||||
"", ReplaceWith(
|
"",
|
||||||
|
ReplaceWith(
|
||||||
"BookmarksFragment()",
|
"BookmarksFragment()",
|
||||||
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment"
|
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
fun newInstance() = BookmarksFragment()
|
fun newInstance() = AllBookmarksFragment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class BookmarksViewModel @Inject constructor(
|
class AllBookmarksViewModel @Inject constructor(
|
||||||
private val repository: BookmarksRepository,
|
private val repository: BookmarksRepository,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
@@ -22,23 +22,18 @@ fun bookmarkLargeAD(
|
|||||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||||
|
|
||||||
binding.root.setOnClickListener(listener)
|
|
||||||
binding.root.setOnLongClickListener(listener)
|
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
size(CoverSizeResolver(binding.imageViewThumb))
|
||||||
placeholder(R.drawable.ic_placeholder)
|
defaultPlaceholders(context)
|
||||||
fallback(R.drawable.ic_placeholder)
|
|
||||||
error(R.drawable.ic_error_placeholder)
|
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
tag(item)
|
tag(item)
|
||||||
decodeRegion(item.scroll)
|
decodeRegion(item.scroll)
|
||||||
source(item.manga.source)
|
source(item.manga.source)
|
||||||
enqueueWith(coil)
|
enqueueWith(coil)
|
||||||
}
|
}
|
||||||
binding.progressView.percent = item.percent
|
binding.progressView.setProgress(item.percent, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,12 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
@@ -21,17 +21,12 @@ fun bookmarkListAD(
|
|||||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||||
|
|
||||||
binding.root.setOnClickListener(listener)
|
|
||||||
binding.root.setOnLongClickListener(listener)
|
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
size(CoverSizeResolver(binding.imageViewThumb))
|
||||||
placeholder(R.drawable.ic_placeholder)
|
defaultPlaceholders(context)
|
||||||
fallback(R.drawable.ic_placeholder)
|
|
||||||
error(R.drawable.ic_error_placeholder)
|
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
tag(item)
|
tag(item)
|
||||||
decodeRegion(item.scroll)
|
decodeRegion(item.scroll)
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
class BookmarksAdapter(
|
class BookmarksAdapter(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
) : BaseListAdapter<Bookmark>() {
|
headerClickListener: ListHeaderClickListener?,
|
||||||
|
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkListAD(coil, lifecycleOwner, clickListener))
|
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
||||||
|
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
||||||
|
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
|
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
|
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||||
|
return findHeader(position)?.getText(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
class BookmarksAdapter(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
|
||||||
headerClickListener: ListHeaderClickListener?,
|
|
||||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
|
||||||
|
|
||||||
init {
|
|
||||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
|
||||||
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
|
||||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
|
||||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
|
||||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
|
||||||
return findHeader(position)?.getText(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.fragment.app.FragmentManager
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import coil.ImageLoader
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
|
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
|
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
|
||||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.plus
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
|
||||||
import org.koitharu.kotatsu.databinding.SheetPagesBinding
|
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class BookmarksSheet :
|
|
||||||
BaseAdaptiveSheet<SheetPagesBinding>(),
|
|
||||||
AdaptiveSheetCallback,
|
|
||||||
OnListItemClickListener<Bookmark> {
|
|
||||||
|
|
||||||
private val viewModel by viewModels<BookmarksSheetViewModel>()
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var coil: ImageLoader
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var settings: AppSettings
|
|
||||||
|
|
||||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
|
||||||
private var spanResolver: MangaListSpanResolver? = null
|
|
||||||
|
|
||||||
private val spanSizeLookup = SpanSizeLookup()
|
|
||||||
private val listCommitCallback = Runnable {
|
|
||||||
spanSizeLookup.invalidateCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
|
|
||||||
return SheetPagesBinding.inflate(inflater, container, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
|
||||||
addSheetCallback(this)
|
|
||||||
spanResolver = MangaListSpanResolver(binding.root.resources)
|
|
||||||
bookmarksAdapter = BookmarksAdapter(
|
|
||||||
coil = coil,
|
|
||||||
lifecycleOwner = viewLifecycleOwner,
|
|
||||||
clickListener = this@BookmarksSheet,
|
|
||||||
headerClickListener = null,
|
|
||||||
)
|
|
||||||
viewBinding?.headerBar?.setTitle(R.string.bookmarks)
|
|
||||||
with(binding.recyclerView) {
|
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
|
||||||
adapter = bookmarksAdapter
|
|
||||||
addOnLayoutChangeListener(spanResolver)
|
|
||||||
spanResolver?.setGridSize(settings.gridSize / 100f, this)
|
|
||||||
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
|
|
||||||
}
|
|
||||||
viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
|
||||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
spanResolver = null
|
|
||||||
bookmarksAdapter = null
|
|
||||||
spanSizeLookup.invalidateCache()
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemClick(item: Bookmark, view: View) {
|
|
||||||
val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
|
|
||||||
if (listener != null) {
|
|
||||||
listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId))
|
|
||||||
} else {
|
|
||||||
val intent = IntentBuilder(view.context)
|
|
||||||
.manga(viewModel.manga)
|
|
||||||
.bookmark(item)
|
|
||||||
.incognito(true)
|
|
||||||
.build()
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStateChanged(sheet: View, newState: Int) {
|
|
||||||
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onThumbnailsChanged(list: List<ListModel>) {
|
|
||||||
val adapter = bookmarksAdapter ?: return
|
|
||||||
if (adapter.itemCount == 0) {
|
|
||||||
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
|
|
||||||
if (position > 0) {
|
|
||||||
val spanCount = spanResolver?.spanCount ?: 0
|
|
||||||
val offset = if (position > spanCount + 1) {
|
|
||||||
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
|
|
||||||
} else {
|
|
||||||
position = 0
|
|
||||||
0
|
|
||||||
}
|
|
||||||
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
|
|
||||||
adapter.setItems(list, listCommitCallback + scrollCallback)
|
|
||||||
} else {
|
|
||||||
adapter.setItems(list, listCommitCallback)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adapter.setItems(list, listCommitCallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
isSpanIndexCacheEnabled = true
|
|
||||||
isSpanGroupIndexCacheEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSpanSize(position: Int): Int {
|
|
||||||
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
|
|
||||||
return when (bookmarksAdapter?.getItemViewType(position)) {
|
|
||||||
ListItemType.PAGE_THUMB.ordinal -> 1
|
|
||||||
else -> total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun invalidateCache() {
|
|
||||||
invalidateSpanGroupIndexCache()
|
|
||||||
invalidateSpanIndexCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val ARG_MANGA = "manga"
|
|
||||||
|
|
||||||
private const val TAG = "BookmarksSheet"
|
|
||||||
|
|
||||||
fun show(fm: FragmentManager, manga: Manga) {
|
|
||||||
BookmarksSheet().withArgs(1) {
|
|
||||||
putParcelable(ARG_MANGA, ParcelableManga(manga))
|
|
||||||
}.showDistinct(fm, TAG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.plus
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
|
||||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class BookmarksSheetViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
|
||||||
bookmarksRepository: BookmarksRepository,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val manga = savedStateHandle.require<ParcelableManga>(BookmarksSheet.ARG_MANGA).manga
|
|
||||||
private val chaptersLazy = SuspendLazy {
|
|
||||||
requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters)
|
|
||||||
}
|
|
||||||
|
|
||||||
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
|
|
||||||
.map { mapList(it) }
|
|
||||||
.withErrorHandling()
|
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
|
|
||||||
|
|
||||||
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {
|
|
||||||
val chapters = chaptersLazy.get()
|
|
||||||
val bookmarksMap = bookmarks.groupBy { it.chapterId }
|
|
||||||
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)
|
|
||||||
for (chapter in chapters) {
|
|
||||||
val b = bookmarksMap[chapter.id]
|
|
||||||
if (b.isNullOrEmpty()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result += ListHeader(chapter.name)
|
|
||||||
result.addAll(b)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -12,18 +11,28 @@ import android.webkit.CookieManager
|
|||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@AndroidEntryPoint
|
||||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||||
|
|
||||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||||
@@ -33,7 +42,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
setDisplayHomeAsUpEnabled(true)
|
setDisplayHomeAsUpEnabled(true)
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
viewBinding.webView.configureForParser(null)
|
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||||
|
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||||
|
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||||
|
viewBinding.webView.configureForParser(userAgent)
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||||
@@ -54,16 +66,6 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
viewBinding.webView.saveState(outState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
||||||
super.onRestoreInstanceState(savedInstanceState)
|
|
||||||
viewBinding.webView.restoreState(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
super.onCreateOptionsMenu(menu)
|
super.onCreateOptionsMenu(menu)
|
||||||
menuInflater.inflate(R.menu.opt_browser, menu)
|
menuInflater.inflate(R.menu.opt_browser, menu)
|
||||||
@@ -105,8 +107,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
viewBinding.webView.stopLoading()
|
if (hasViewBinding()) {
|
||||||
viewBinding.webView.destroy()
|
viewBinding.webView.stopLoading()
|
||||||
|
viewBinding.webView.destroy()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
@@ -136,11 +140,13 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val EXTRA_TITLE = "title"
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_SOURCE = "source"
|
||||||
|
|
||||||
fun newIntent(context: Context, url: String, title: String?): Intent {
|
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
|
||||||
return Intent(context, BrowserActivity::class.java)
|
return Intent(context, BrowserActivity::class.java)
|
||||||
.setData(Uri.parse(url))
|
.setData(Uri.parse(url))
|
||||||
.putExtra(EXTRA_TITLE, title)
|
.putExtra(EXTRA_TITLE, title)
|
||||||
|
.putExtra(EXTRA_SOURCE, source?.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
package org.koitharu.kotatsu.browser.cloudflare
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.core.app.NotificationChannelCompat
|
import androidx.core.app.NotificationChannelCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
@@ -11,8 +14,9 @@ import coil.request.ErrorResult
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
class CaptchaNotifier(
|
class CaptchaNotifier(
|
||||||
@@ -20,7 +24,7 @@ class CaptchaNotifier(
|
|||||||
) : EventListener {
|
) : EventListener {
|
||||||
|
|
||||||
fun notify(exception: CloudFlareProtectedException) {
|
fun notify(exception: CloudFlareProtectedException) {
|
||||||
if (!context.checkNotificationPermission()) {
|
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
@@ -33,16 +37,17 @@ class CaptchaNotifier(
|
|||||||
.build()
|
.build()
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
|
|
||||||
val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers)
|
val intent = CloudFlareActivity.newIntent(context, exception)
|
||||||
.setData(exception.url.toUri())
|
.setData(exception.url.toUri())
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setContentTitle(channel.name)
|
.setContentTitle(channel.name)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.setGroup(GROUP_CAPTCHA)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setVisibility(
|
.setVisibility(
|
||||||
if (exception.source?.contentType == ContentType.HENTAI) {
|
if (exception.source?.isNsfw() == true) {
|
||||||
NotificationCompat.VISIBILITY_SECRET
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
} else {
|
} else {
|
||||||
NotificationCompat.VISIBILITY_PUBLIC
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
@@ -51,12 +56,25 @@ class CaptchaNotifier(
|
|||||||
.setContentText(
|
.setContentText(
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.captcha_required_summary,
|
R.string.captcha_required_summary,
|
||||||
exception.source?.title ?: context.getString(R.string.app_name),
|
exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
||||||
.build()
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
manager.notify(TAG, exception.source.hashCode(), notification)
|
val actionIntent = PendingIntentCompat.getActivity(
|
||||||
|
context, SETTINGS_ACTION_CODE,
|
||||||
|
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||||
|
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID),
|
||||||
|
0, false,
|
||||||
|
)
|
||||||
|
notification.addAction(
|
||||||
|
R.drawable.ic_settings,
|
||||||
|
context.getString(R.string.notifications_settings),
|
||||||
|
actionIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
manager.notify(TAG, exception.source.hashCode(), notification.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismiss(source: MangaSource) {
|
fun dismiss(source: MangaSource) {
|
||||||
@@ -82,5 +100,7 @@ class CaptchaNotifier(
|
|||||||
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
|
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
|
||||||
private const val CHANNEL_ID = "captcha"
|
private const val CHANNEL_ID = "captcha"
|
||||||
private const val TAG = CHANNEL_ID
|
private const val TAG = CHANNEL_ID
|
||||||
|
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
||||||
|
private const val SETTINGS_ACTION_CODE = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
package org.koitharu.kotatsu.browser.cloudflare
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -23,12 +24,15 @@ import okhttp3.HttpUrl
|
|||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -52,7 +56,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
setDisplayHomeAsUpEnabled(true)
|
setDisplayHomeAsUpEnabled(true)
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
val url = intent?.dataString.orEmpty()
|
val url = intent?.dataString
|
||||||
|
if (url.isNullOrEmpty()) {
|
||||||
|
finishAfterTransition()
|
||||||
|
return
|
||||||
|
}
|
||||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
||||||
viewBinding.webView.webViewClient = cfClient
|
viewBinding.webView.webViewClient = cfClient
|
||||||
@@ -60,12 +68,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
onBackPressedDispatcher.addCallback(it)
|
onBackPressedDispatcher.addCallback(it)
|
||||||
}
|
}
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState == null) {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (url.isEmpty()) {
|
|
||||||
finishAfterTransition()
|
|
||||||
} else {
|
|
||||||
onTitleChanged(getString(R.string.loading_), url)
|
onTitleChanged(getString(R.string.loading_), url)
|
||||||
viewBinding.webView.loadUrl(url)
|
viewBinding.webView.loadUrl(url)
|
||||||
}
|
}
|
||||||
@@ -81,16 +84,6 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
viewBinding.webView.saveState(outState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
||||||
super.onRestoreInstanceState(savedInstanceState)
|
|
||||||
viewBinding.webView.restoreState(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
menuInflater.inflate(R.menu.opt_captcha, menu)
|
menuInflater.inflate(R.menu.opt_captcha, menu)
|
||||||
return super.onCreateOptionsMenu(menu)
|
return super.onCreateOptionsMenu(menu)
|
||||||
@@ -147,6 +140,10 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
|
|
||||||
override fun onCheckPassed() {
|
override fun onCheckPassed() {
|
||||||
pendingResult = RESULT_OK
|
pendingResult = RESULT_OK
|
||||||
|
val source = intent?.getStringExtra(ARG_SOURCE)
|
||||||
|
if (source != null) {
|
||||||
|
CaptchaNotifier(this).dismiss(MangaSource(source))
|
||||||
|
}
|
||||||
finishAfterTransition()
|
finishAfterTransition()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,18 +176,17 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
|
|
||||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
||||||
cookieJar.removeCookies(url) { cookie ->
|
cookieJar.removeCookies(url) { cookie ->
|
||||||
val name = cookie.name
|
CloudFlareHelper.isCloudFlareCookie(cookie.name)
|
||||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
|
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
|
||||||
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
|
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
||||||
return newIntent(context, input.first, input.second)
|
return newIntent(context, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||||
return TaggedActivityResult(TAG, resultCode)
|
return resultCode == Activity.RESULT_OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,13 +194,23 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
|
|
||||||
const val TAG = "CloudFlareActivity"
|
const val TAG = "CloudFlareActivity"
|
||||||
private const val ARG_UA = "ua"
|
private const val ARG_UA = "ua"
|
||||||
|
private const val ARG_SOURCE = "_source"
|
||||||
|
|
||||||
fun newIntent(
|
fun newIntent(context: Context, exception: CloudFlareProtectedException) = newIntent(
|
||||||
|
context = context,
|
||||||
|
url = exception.url,
|
||||||
|
source = exception.source,
|
||||||
|
headers = exception.headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun newIntent(
|
||||||
context: Context,
|
context: Context,
|
||||||
url: String,
|
url: String,
|
||||||
|
source: MangaSource?,
|
||||||
headers: Headers?,
|
headers: Headers?,
|
||||||
) = Intent(context, CloudFlareActivity::class.java).apply {
|
) = Intent(context, CloudFlareActivity::class.java).apply {
|
||||||
data = url.toUri()
|
data = url.toUri()
|
||||||
|
putExtra(ARG_SOURCE, source?.name)
|
||||||
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
||||||
putExtra(ARG_UA, it)
|
putExtra(ARG_UA, it)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ package org.koitharu.kotatsu.browser.cloudflare
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import org.koitharu.kotatsu.browser.BrowserClient
|
import org.koitharu.kotatsu.browser.BrowserClient
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
|
||||||
private const val CF_CLEARANCE = "cf_clearance"
|
|
||||||
private const val LOOP_COUNTER = 3
|
private const val LOOP_COUNTER = 3
|
||||||
|
|
||||||
class CloudFlareClient(
|
class CloudFlareClient(
|
||||||
@@ -50,8 +49,5 @@ class CloudFlareClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getClearance(): String? {
|
private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
|
||||||
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
|
|
||||||
.find { it.name == CF_CLEARANCE }?.value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,35 +26,35 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||||
import org.koitharu.kotatsu.core.cache.ContentCache
|
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
|
||||||
import org.koitharu.kotatsu.core.cache.StubContentCache
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
|
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
||||||
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||||
|
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
|
||||||
|
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
||||||
import org.koitharu.kotatsu.local.data.CacheDir
|
import org.koitharu.kotatsu.local.data.CacheDir
|
||||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||||
|
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
|
|
||||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
||||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||||
import org.koitharu.kotatsu.widget.WidgetUpdater
|
import org.koitharu.kotatsu.widget.WidgetUpdater
|
||||||
|
import javax.inject.Provider
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -72,8 +72,9 @@ interface AppModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideNetworkState(
|
fun provideNetworkState(
|
||||||
@ApplicationContext context: Context
|
@ApplicationContext context: Context,
|
||||||
) = NetworkState(context.connectivityManager)
|
settings: AppSettings,
|
||||||
|
) = NetworkState(context.connectivityManager, settings)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -87,7 +88,7 @@ interface AppModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideCoil(
|
fun provideCoil(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
@MangaHttpClient okHttpClient: OkHttpClient,
|
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
imageProxyInterceptor: ImageProxyInterceptor,
|
imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||||
@@ -99,13 +100,18 @@ interface AppModule {
|
|||||||
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
val okHttpClientLazy = lazy {
|
||||||
|
okHttpClientProvider.get().newBuilder().cache(null).build()
|
||||||
|
}
|
||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
|
.okHttpClient { okHttpClientLazy.value }
|
||||||
.interceptorDispatcher(Dispatchers.Default)
|
.interceptorDispatcher(Dispatchers.Default)
|
||||||
.fetcherDispatcher(Dispatchers.IO)
|
.fetcherDispatcher(Dispatchers.Default)
|
||||||
.decoderDispatcher(Dispatchers.Default)
|
.decoderDispatcher(Dispatchers.IO)
|
||||||
.transformationDispatcher(Dispatchers.Default)
|
.transformationDispatcher(Dispatchers.Default)
|
||||||
.diskCache(diskCacheFactory)
|
.diskCache(diskCacheFactory)
|
||||||
|
.respectCacheHeaders(false)
|
||||||
|
.networkObserverEnabled(false)
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
.allowRgb565(context.isLowRamDevice())
|
.allowRgb565(context.isLowRamDevice())
|
||||||
.eventListener(CaptchaNotifier(context))
|
.eventListener(CaptchaNotifier(context))
|
||||||
@@ -113,7 +119,8 @@ interface AppModule {
|
|||||||
ComponentRegistry.Builder()
|
ComponentRegistry.Builder()
|
||||||
.add(SvgDecoder.Factory())
|
.add(SvgDecoder.Factory())
|
||||||
.add(CbzFetcher.Factory())
|
.add(CbzFetcher.Factory())
|
||||||
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
|
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
||||||
|
.add(MangaPageKeyer())
|
||||||
.add(pageFetcherFactory)
|
.add(pageFetcherFactory)
|
||||||
.add(imageProxyInterceptor)
|
.add(imageProxyInterceptor)
|
||||||
.add(coverRestoreInterceptor)
|
.add(coverRestoreInterceptor)
|
||||||
@@ -147,27 +154,15 @@ interface AppModule {
|
|||||||
fun provideActivityLifecycleCallbacks(
|
fun provideActivityLifecycleCallbacks(
|
||||||
appProtectHelper: AppProtectHelper,
|
appProtectHelper: AppProtectHelper,
|
||||||
activityRecreationHandle: ActivityRecreationHandle,
|
activityRecreationHandle: ActivityRecreationHandle,
|
||||||
incognitoModeIndicator: IncognitoModeIndicator,
|
|
||||||
acraScreenLogger: AcraScreenLogger,
|
acraScreenLogger: AcraScreenLogger,
|
||||||
|
screenshotPolicyHelper: ScreenshotPolicyHelper,
|
||||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||||
appProtectHelper,
|
appProtectHelper,
|
||||||
activityRecreationHandle,
|
activityRecreationHandle,
|
||||||
incognitoModeIndicator,
|
|
||||||
acraScreenLogger,
|
acraScreenLogger,
|
||||||
|
screenshotPolicyHelper,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideContentCache(
|
|
||||||
application: Application,
|
|
||||||
): ContentCache {
|
|
||||||
return if (application.isLowRamDevice()) {
|
|
||||||
StubContentCache()
|
|
||||||
} else {
|
|
||||||
MemoryContentCache(application)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@LocalStorageChanges
|
@LocalStorageChanges
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.work.Configuration
|
|||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
@@ -28,6 +29,9 @@ import org.koitharu.kotatsu.core.os.AppValidator
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
|
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||||
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -37,7 +41,7 @@ import javax.inject.Provider
|
|||||||
open class BaseApp : Application(), Configuration.Provider {
|
open class BaseApp : Application(), Configuration.Provider {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer>
|
lateinit var databaseObserversProvider: Provider<Set<@JvmSuppressWildcards InvalidationTracker.Observer>>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
||||||
@@ -60,6 +64,13 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
lateinit var workManagerProvider: Provider<WorkManager>
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@LocalStorageChanges
|
||||||
|
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() = Configuration.Builder()
|
get() = Configuration.Builder()
|
||||||
.setWorkerFactory(workerFactory)
|
.setWorkerFactory(workerFactory)
|
||||||
@@ -82,12 +93,13 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
}
|
}
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
|
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||||
}
|
}
|
||||||
workScheduleManager.init()
|
workScheduleManager.init()
|
||||||
WorkServiceStopHelper(workManagerProvider).setup()
|
WorkServiceStopHelper(workManagerProvider).setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
override fun attachBaseContext(base: Context) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
initAcra {
|
initAcra {
|
||||||
buildConfigClass = BuildConfig::class.java
|
buildConfigClass = BuildConfig::class.java
|
||||||
@@ -123,7 +135,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
@WorkerThread
|
@WorkerThread
|
||||||
private fun setupDatabaseObservers() {
|
private fun setupDatabaseObservers() {
|
||||||
val tracker = database.get().invalidationTracker
|
val tracker = database.get().invalidationTracker
|
||||||
databaseObservers.forEach {
|
databaseObserversProvider.get().forEach {
|
||||||
tracker.addObserver(it)
|
tracker.addObserver(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.BadParcelableException
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.report
|
import org.koitharu.kotatsu.core.util.ext.report
|
||||||
|
|
||||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
class ErrorReporterReceiver : BroadcastReceiver() {
|
||||||
@@ -22,12 +24,15 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
|||||||
private const val EXTRA_ERROR = "err"
|
private const val EXTRA_ERROR = "err"
|
||||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
||||||
|
|
||||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
|
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
||||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||||
intent.setAction(ACTION_REPORT)
|
intent.setAction(ACTION_REPORT)
|
||||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
||||||
intent.putExtra(EXTRA_ERROR, e)
|
intent.putExtra(EXTRA_ERROR, e)
|
||||||
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
|
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||||
|
} catch (e: BadParcelableException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.auto.service.AutoService
|
|
||||||
import org.acra.builder.ReportBuilder
|
|
||||||
import org.acra.config.CoreConfiguration
|
|
||||||
import org.acra.config.ReportingAdministrator
|
|
||||||
|
|
||||||
@AutoService(ReportingAdministrator::class)
|
|
||||||
class ErrorReportingAdmin : ReportingAdministrator {
|
|
||||||
|
|
||||||
override fun shouldStartCollecting(
|
|
||||||
context: Context,
|
|
||||||
config: CoreConfiguration,
|
|
||||||
reportBuilder: ReportBuilder
|
|
||||||
): Boolean {
|
|
||||||
return reportBuilder.exception?.isDeadOs() != true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Throwable.isDeadOs(): Boolean {
|
|
||||||
val className = javaClass.simpleName
|
|
||||||
return className == "DeadSystemException" || className == "DeadSystemRuntimeException" || cause?.isDeadOs() == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,17 @@ import kotlinx.coroutines.CoroutineStart
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okhttp3.internal.closeQuietly
|
||||||
import okio.Closeable
|
import okio.Closeable
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
|
import java.util.zip.ZipException
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
class BackupZipInput(val file: File) : Closeable {
|
class BackupZipInput private constructor(val file: File) : Closeable {
|
||||||
|
|
||||||
private val zipFile = ZipFile(file)
|
private val zipFile = ZipFile(file)
|
||||||
|
|
||||||
@@ -36,9 +39,30 @@ class BackupZipInput(val file: File) : Closeable {
|
|||||||
fun cleanupAsync() {
|
fun cleanupAsync() {
|
||||||
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
||||||
runCatching {
|
runCatching {
|
||||||
close()
|
closeQuietly()
|
||||||
file.delete()
|
file.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun from(file: File): BackupZipInput {
|
||||||
|
var res: BackupZipInput? = null
|
||||||
|
return try {
|
||||||
|
res = BackupZipInput(file)
|
||||||
|
if (res.zipFile.getEntry("index") == null) {
|
||||||
|
throw BadBackupFormatException(null)
|
||||||
|
}
|
||||||
|
res
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
res?.closeQuietly()
|
||||||
|
throw if (exception is ZipException) {
|
||||||
|
BadBackupFormatException(exception)
|
||||||
|
} else {
|
||||||
|
exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptibl
|
|||||||
val filename = buildString {
|
val filename = buildString {
|
||||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||||
append('_')
|
append('_')
|
||||||
append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
|
append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")))
|
||||||
append(".bk.zip")
|
append(".bk.zip")
|
||||||
}
|
}
|
||||||
BackupZipOutput(File(dir, filename))
|
BackupZipOutput(File(dir, filename))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
|||||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||||
|
|
||||||
class JsonDeserializer(private val json: JSONObject) {
|
class JsonDeserializer(private val json: JSONObject) {
|
||||||
@@ -84,6 +85,9 @@ class JsonDeserializer(private val json: JSONObject) {
|
|||||||
source = json.getString("source"),
|
source = json.getString("source"),
|
||||||
isEnabled = json.getBoolean("enabled"),
|
isEnabled = json.getBoolean("enabled"),
|
||||||
sortKey = json.getInt("sort_key"),
|
sortKey = json.getInt("sort_key"),
|
||||||
|
addedIn = json.getIntOrDefault("added_in", 0),
|
||||||
|
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
||||||
|
isPinned = json.getBooleanOrDefault("pinned", false),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toMap(): Map<String, Any?> {
|
fun toMap(): Map<String, Any?> {
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
|||||||
put("source", e.source)
|
put("source", e.source)
|
||||||
put("enabled", e.isEnabled)
|
put("enabled", e.isEnabled)
|
||||||
put("sort_key", e.sortKey)
|
put("sort_key", e.sortKey)
|
||||||
|
put("added_in", e.addedIn)
|
||||||
|
put("used_at", e.lastUsedAt)
|
||||||
|
put("pinned", e.isPinned)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.cache
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
interface ContentCache {
|
|
||||||
|
|
||||||
val isCachingEnabled: Boolean
|
|
||||||
|
|
||||||
suspend fun getDetails(source: MangaSource, url: String): Manga?
|
|
||||||
|
|
||||||
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
|
|
||||||
|
|
||||||
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
|
|
||||||
|
|
||||||
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
|
|
||||||
|
|
||||||
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
|
|
||||||
|
|
||||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
|
|
||||||
|
|
||||||
fun clear(source: MangaSource)
|
|
||||||
|
|
||||||
data class Key(
|
|
||||||
val source: MangaSource,
|
|
||||||
val url: String,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,18 +2,19 @@ package org.koitharu.kotatsu.core.cache
|
|||||||
|
|
||||||
import androidx.collection.LruCache
|
import androidx.collection.LruCache
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
|
||||||
|
|
||||||
class ExpiringLruCache<T>(
|
class ExpiringLruCache<T>(
|
||||||
val maxSize: Int,
|
val maxSize: Int,
|
||||||
private val lifetime: Long,
|
private val lifetime: Long,
|
||||||
private val timeUnit: TimeUnit,
|
private val timeUnit: TimeUnit,
|
||||||
) : Iterable<ContentCache.Key> {
|
) : Iterable<CacheKey> {
|
||||||
|
|
||||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
|
||||||
|
|
||||||
override fun iterator(): Iterator<ContentCache.Key> = cache.snapshot().keys.iterator()
|
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
|
||||||
|
|
||||||
operator fun get(key: ContentCache.Key): T? {
|
operator fun get(key: CacheKey): T? {
|
||||||
val value = cache[key] ?: return null
|
val value = cache[key] ?: return null
|
||||||
if (value.isExpired) {
|
if (value.isExpired) {
|
||||||
cache.remove(key)
|
cache.remove(key)
|
||||||
@@ -21,7 +22,7 @@ class ExpiringLruCache<T>(
|
|||||||
return value.get()
|
return value.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
operator fun set(key: ContentCache.Key, value: T) {
|
operator fun set(key: CacheKey, value: T) {
|
||||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ class ExpiringLruCache<T>(
|
|||||||
cache.trimToSize(size)
|
cache.trimToSize(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(key: ContentCache.Key) {
|
fun remove(key: CacheKey) {
|
||||||
cache.remove(key)
|
cache.remove(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,48 +3,54 @@ package org.koitharu.kotatsu.core.cache
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.ComponentCallbacks2
|
import android.content.ComponentCallbacks2
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
|
@Singleton
|
||||||
|
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
|
||||||
|
|
||||||
|
private val isLowRam = application.isLowRamDevice()
|
||||||
|
|
||||||
|
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
||||||
|
private val pagesCache =
|
||||||
|
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
||||||
|
private val relatedMangaCache =
|
||||||
|
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
application.registerComponentCallbacks(this)
|
application.registerComponentCallbacks(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
|
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||||
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
|
return detailsCache[Key(source, url)]?.awaitOrNull()
|
||||||
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
|
|
||||||
|
|
||||||
override val isCachingEnabled: Boolean = true
|
|
||||||
|
|
||||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
|
||||||
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
||||||
detailsCache[ContentCache.Key(source, url)] = details
|
detailsCache[Key(source, url)] = details
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
||||||
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
return pagesCache[Key(source, url)]?.awaitOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
||||||
pagesCache[ContentCache.Key(source, url)] = pages
|
pagesCache[Key(source, url)] = pages
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
||||||
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
return relatedMangaCache[Key(source, url)]?.awaitOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
||||||
relatedMangaCache[ContentCache.Key(source, url)] = related
|
relatedMangaCache[Key(source, url)] = related
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clear(source: MangaSource) {
|
fun clear(source: MangaSource) {
|
||||||
clearCache(detailsCache, source)
|
clearCache(detailsCache, source)
|
||||||
clearCache(pagesCache, source)
|
clearCache(pagesCache, source)
|
||||||
clearCache(relatedMangaCache, source)
|
clearCache(relatedMangaCache, source)
|
||||||
@@ -81,4 +87,9 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class Key(
|
||||||
|
val source: MangaSource,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.cache
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
class StubContentCache : ContentCache {
|
|
||||||
|
|
||||||
override val isCachingEnabled: Boolean = false
|
|
||||||
|
|
||||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
|
|
||||||
|
|
||||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
|
|
||||||
|
|
||||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
|
|
||||||
|
|
||||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
|
|
||||||
|
|
||||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
|
|
||||||
|
|
||||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
|
|
||||||
|
|
||||||
override fun clear(source: MangaSource) = Unit
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,11 @@ import org.koitharu.kotatsu.core.db.migrations.Migration15To16
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||||
@@ -47,6 +51,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|||||||
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
||||||
import org.koitharu.kotatsu.history.data.HistoryDao
|
import org.koitharu.kotatsu.history.data.HistoryDao
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao
|
||||||
|
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
import org.koitharu.kotatsu.stats.data.StatsDao
|
||||||
@@ -57,14 +63,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||||
|
|
||||||
const val DATABASE_VERSION = 19
|
const val DATABASE_VERSION = 23
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION,
|
||||||
)
|
)
|
||||||
@@ -95,6 +101,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
abstract fun getSourcesDao(): MangaSourcesDao
|
abstract fun getSourcesDao(): MangaSourcesDao
|
||||||
|
|
||||||
abstract fun getStatsDao(): StatsDao
|
abstract fun getStatsDao(): StatsDao
|
||||||
|
|
||||||
|
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||||
@@ -116,6 +124,10 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration16To17(context),
|
Migration16To17(context),
|
||||||
Migration17To18(),
|
Migration17To18(),
|
||||||
Migration18To19(),
|
Migration18To19(),
|
||||||
|
Migration19To20(),
|
||||||
|
Migration20To21(),
|
||||||
|
Migration21To22(),
|
||||||
|
Migration22To23(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db
|
||||||
|
|
||||||
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
|
import java.util.LinkedList
|
||||||
|
|
||||||
|
class MangaQueryBuilder(
|
||||||
|
private val table: String,
|
||||||
|
private val conditionCallback: ConditionCallback
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var filterOptions: Collection<ListFilterOption> = emptyList()
|
||||||
|
private var whereConditions = LinkedList<String>()
|
||||||
|
private var orderBy: String? = null
|
||||||
|
private var groupBy: String? = null
|
||||||
|
private var extraJoins: String? = null
|
||||||
|
private var limit: Int = 0
|
||||||
|
|
||||||
|
fun filters(options: Collection<ListFilterOption>) = apply {
|
||||||
|
filterOptions = options
|
||||||
|
}
|
||||||
|
|
||||||
|
fun where(condition: String) = apply {
|
||||||
|
whereConditions.add(condition)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun orderBy(orderBy: String?) = apply {
|
||||||
|
this@MangaQueryBuilder.orderBy = orderBy
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groupBy(groupBy: String?) = apply {
|
||||||
|
this@MangaQueryBuilder.groupBy = groupBy
|
||||||
|
}
|
||||||
|
|
||||||
|
fun limit(limit: Int) = apply {
|
||||||
|
this@MangaQueryBuilder.limit = limit
|
||||||
|
}
|
||||||
|
|
||||||
|
fun join(join: String?) = apply {
|
||||||
|
extraJoins = join
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build() = buildString {
|
||||||
|
append("SELECT * FROM ")
|
||||||
|
append(table)
|
||||||
|
extraJoins?.let {
|
||||||
|
append(' ')
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
if (whereConditions.isNotEmpty()) {
|
||||||
|
whereConditions.joinTo(
|
||||||
|
buffer = this,
|
||||||
|
prefix = " WHERE ",
|
||||||
|
separator = " AND ",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filterOptions.isNotEmpty()) {
|
||||||
|
if (whereConditions.isEmpty()) {
|
||||||
|
append(" WHERE")
|
||||||
|
} else {
|
||||||
|
append(" AND")
|
||||||
|
}
|
||||||
|
var isFirst = true
|
||||||
|
val groupedOptions = filterOptions.groupBy { it.groupKey }
|
||||||
|
for ((_, group) in groupedOptions) {
|
||||||
|
if (group.isEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isFirst) {
|
||||||
|
isFirst = false
|
||||||
|
append(' ')
|
||||||
|
} else {
|
||||||
|
append(" AND ")
|
||||||
|
}
|
||||||
|
if (group.size > 1) {
|
||||||
|
group.joinTo(
|
||||||
|
buffer = this,
|
||||||
|
separator = " OR ",
|
||||||
|
prefix = "(",
|
||||||
|
postfix = ")",
|
||||||
|
transform = ::getConditionOrThrow,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
append(getConditionOrThrow(group.single()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupBy?.let {
|
||||||
|
append(" GROUP BY ")
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
orderBy?.let {
|
||||||
|
append(" ORDER BY ")
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
if (limit > 0) {
|
||||||
|
append(" LIMIT ")
|
||||||
|
append(limit)
|
||||||
|
}
|
||||||
|
}.let { SimpleSQLiteQuery(it) }
|
||||||
|
|
||||||
|
private fun getConditionOrThrow(option: ListFilterOption): String = when (option) {
|
||||||
|
is ListFilterOption.Inverted -> "NOT(${getConditionOrThrow(option.option)})"
|
||||||
|
else -> requireNotNull(conditionCallback.getCondition(option)) {
|
||||||
|
"Unsupported filter option $option"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface ConditionCallback {
|
||||||
|
|
||||||
|
fun getCondition(option: ListFilterOption): String?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ abstract class MangaDao {
|
|||||||
@Query("SELECT * FROM manga WHERE source = :source")
|
@Query("SELECT * FROM manga WHERE source = :source")
|
||||||
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
|
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
|
||||||
|
|
||||||
|
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
|
||||||
|
abstract suspend fun findAuthors(query: String, limit: Int): List<String>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
||||||
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
||||||
@@ -37,7 +40,7 @@ abstract class MangaDao {
|
|||||||
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
|
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
|
||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(manga: MangaEntity)
|
protected abstract suspend fun upsert(manga: MangaEntity)
|
||||||
|
|
||||||
@Update(onConflict = OnConflictStrategy.IGNORE)
|
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||||
abstract suspend fun update(manga: MangaEntity): Int
|
abstract suspend fun update(manga: MangaEntity): Int
|
||||||
|
|||||||
@@ -11,22 +11,26 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
|||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.intellij.lang.annotations.Language
|
import org.intellij.lang.annotations.Language
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class MangaSourcesDao {
|
abstract class MangaSourcesDao {
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||||
abstract suspend fun findAll(): List<MangaSourceEntity>
|
abstract suspend fun findAll(): List<MangaSourceEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key")
|
@Query("SELECT source FROM sources WHERE enabled = 1")
|
||||||
abstract suspend fun findAllDisabled(): List<MangaSourceEntity>
|
abstract suspend fun findAllEnabledNames(): List<String>
|
||||||
|
|
||||||
@Query("SELECT * FROM sources WHERE enabled = 0")
|
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
||||||
abstract fun observeDisabled(): Flow<List<MangaSourceEntity>>
|
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
@Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
|
||||||
|
abstract suspend fun findLastUsed(limit: Int): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||||
|
|
||||||
@Query("SELECT enabled FROM sources WHERE source = :source")
|
@Query("SELECT enabled FROM sources WHERE source = :source")
|
||||||
@@ -41,6 +45,12 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
||||||
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET used_at = :value WHERE source = :source")
|
||||||
|
abstract suspend fun setLastUsed(source: String, value: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
||||||
|
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
@Transaction
|
@Transaction
|
||||||
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
||||||
@@ -48,11 +58,14 @@ abstract class MangaSourcesDao {
|
|||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(entry: MangaSourceEntity)
|
abstract suspend fun upsert(entry: MangaSourceEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE pinned = 1")
|
||||||
|
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
||||||
|
|
||||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
||||||
val orderBy = getOrderBy(order)
|
val orderBy = getOrderBy(order)
|
||||||
|
|
||||||
@Language("RoomSql")
|
@Language("RoomSql")
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
||||||
return observeImpl(query)
|
return observeImpl(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +73,7 @@ abstract class MangaSourcesDao {
|
|||||||
val orderBy = getOrderBy(order)
|
val orderBy = getOrderBy(order)
|
||||||
|
|
||||||
@Language("RoomSql")
|
@Language("RoomSql")
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
||||||
return findAllImpl(query)
|
return findAllImpl(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +84,9 @@ abstract class MangaSourcesDao {
|
|||||||
source = source,
|
source = source,
|
||||||
isEnabled = isEnabled,
|
isEnabled = isEnabled,
|
||||||
sortKey = getMaxSortKey() + 1,
|
sortKey = getMaxSortKey() + 1,
|
||||||
|
addedIn = BuildConfig.VERSION_CODE,
|
||||||
|
lastUsedAt = 0,
|
||||||
|
isPinned = false,
|
||||||
)
|
)
|
||||||
upsert(entity)
|
upsert(entity)
|
||||||
}
|
}
|
||||||
@@ -89,5 +105,6 @@ abstract class MangaSourcesDao {
|
|||||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
||||||
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
||||||
|
SourcesSortOrder.LAST_USED -> "used_at DESC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.db.dao
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||||
|
|
||||||
@@ -13,6 +15,9 @@ abstract class PreferencesDao {
|
|||||||
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
||||||
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
||||||
|
|
||||||
|
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
|
||||||
|
abstract suspend fun resetColorFilters()
|
||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(pref: MangaPrefsEntity)
|
abstract suspend fun upsert(pref: MangaPrefsEntity)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,62 @@
|
|||||||
package org.koitharu.kotatsu.core.db.dao
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
import androidx.room.*
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.RawQuery
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface TrackLogsDao {
|
abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback {
|
||||||
|
|
||||||
@Transaction
|
fun observeAll(
|
||||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
limit: Int,
|
||||||
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
filterOptions: Set<ListFilterOption>,
|
||||||
|
): Flow<List<TrackLogWithManga>> = observeAllImpl(
|
||||||
|
MangaQueryBuilder("track_logs", this)
|
||||||
|
.filters(filterOptions)
|
||||||
|
.limit(limit)
|
||||||
|
.orderBy("created_at DESC")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
|
||||||
|
abstract fun observeUnreadCount(): Flow<Int>
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs")
|
@Query("DELETE FROM track_logs")
|
||||||
suspend fun clear()
|
abstract suspend fun clear()
|
||||||
|
|
||||||
|
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
|
||||||
|
abstract suspend fun markAsRead(id: Long)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insert(entity: TrackLogEntity): Long
|
abstract suspend fun insert(entity: TrackLogEntity): Long
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
|
|
||||||
suspend fun removeAll(mangaId: Long)
|
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||||
suspend fun gc()
|
abstract suspend fun gc()
|
||||||
|
|
||||||
|
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
|
||||||
|
abstract suspend fun trim(size: Int)
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM track_logs")
|
@Query("SELECT COUNT(*) FROM track_logs")
|
||||||
suspend fun count(): Int
|
abstract suspend fun count(): Int
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@RawQuery(observedEntities = [TrackLogEntity::class])
|
||||||
|
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<TrackLogWithManga>>
|
||||||
|
|
||||||
|
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
||||||
|
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id)"
|
||||||
|
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id AND favourites.category_id = ${option.category.id})"
|
||||||
|
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = track_logs.manga_id AND tag_id = ${option.tagId})"
|
||||||
|
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = track_logs.manga_id) = 1"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
|||||||
|
|
||||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||||
|
|
||||||
|
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
|
||||||
|
|
||||||
// Model to entity
|
// Model to entity
|
||||||
|
|
||||||
fun Manga.toEntity() = MangaEntity(
|
fun Manga.toEntity() = MangaEntity(
|
||||||
|
|||||||
@@ -14,4 +14,7 @@ data class MangaSourceEntity(
|
|||||||
val source: String,
|
val source: String,
|
||||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||||
|
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||||
|
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
|
||||||
|
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.content.Context
|
|||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
|
||||||
class Migration16To17(context: Context) : Migration(16, 17) {
|
class Migration16To17(context: Context) : Migration(16, 17) {
|
||||||
|
|
||||||
@@ -15,11 +15,8 @@ class Migration16To17(context: Context) : Migration(16, 17) {
|
|||||||
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
||||||
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
|
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
|
||||||
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
|
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
|
||||||
val sources = MangaSource.entries
|
val sources = MangaParserSource.entries
|
||||||
for (source in sources) {
|
for (source in sources) {
|
||||||
if (source == MangaSource.LOCAL) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val name = source.name
|
val name = source.name
|
||||||
val isHidden = name in hiddenSources
|
val isHidden = name in hiddenSources
|
||||||
var sortKey = order.indexOf(name)
|
var sortKey = order.indexOf(name)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration19To20 : Migration(19, 20) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("CREATE TABLE tracks_bk (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id))")
|
||||||
|
db.execSQL("INSERT INTO tracks_bk SELECT manga_id, chapters_total, last_chapter_id, chapters_new, last_check, last_notified_id FROM tracks")
|
||||||
|
db.execSQL("DROP TABLE tracks")
|
||||||
|
db.execSQL("CREATE TABLE tracks (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||||
|
db.execSQL("INSERT INTO tracks SELECT manga_id, last_chapter_id, chapters_new, last_check AS last_check_time, 0 AS last_chapter_date, 0 AS last_result FROM tracks_bk")
|
||||||
|
db.execSQL("DROP TABLE tracks_bk")
|
||||||
|
|
||||||
|
db.execSQL("ALTER TABLE track_logs ADD COLUMN `unread` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration20To21 : Migration(20, 21) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration21To22 : Migration(21, 22) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE sources ADD COLUMN `used_at` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE sources ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration22To23 : Migration(22, 23) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class BadBackupFormatException(cause: Throwable?) : IOException(cause)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class CloudFlareBlockedException(
|
||||||
|
val url: String,
|
||||||
|
val source: MangaSource?,
|
||||||
|
) : IOException("Blocked by CloudFlare")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
class IncompatiblePluginException(
|
||||||
|
val name: String?,
|
||||||
|
cause: Throwable?,
|
||||||
|
) : RuntimeException(cause)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
|
||||||
|
class NoDataReceivedException(
|
||||||
|
url: String,
|
||||||
|
) : IOException("No data has been received from $url")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import java.net.ProtocolException
|
||||||
|
|
||||||
|
class ProxyConfigException : ProtocolException("Wrong proxy configuration")
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import okio.IOException
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
class TooManyRequestExceptions(
|
|
||||||
val url: String,
|
|
||||||
val retryAt: Instant?,
|
|
||||||
) : IOException() {
|
|
||||||
val retryAfter: Long
|
|
||||||
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
|
|
||||||
}
|
|
||||||
@@ -1,62 +1,74 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||||
|
|
||||||
import androidx.activity.result.ActivityResultCallback
|
import android.content.Context
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.ActivityResultCaller
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.ArrayMap
|
import androidx.collection.MutableScatterMap
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.FragmentManager
|
||||||
import androidx.fragment.app.FragmentActivity
|
import dagger.assisted.Assisted
|
||||||
import okhttp3.Headers
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
||||||
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
|
import java.security.cert.CertPathValidatorException
|
||||||
|
import javax.inject.Provider
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
class ExceptionResolver @AssistedInject constructor(
|
||||||
|
@Assisted private val host: Host,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||||
|
) {
|
||||||
|
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||||
|
|
||||||
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
|
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||||
private val activity: FragmentActivity?
|
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||||
private val fragment: Fragment?
|
|
||||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
|
||||||
private val cloudflareContract: ActivityResultLauncher<Pair<String, Headers?>>
|
|
||||||
|
|
||||||
constructor(activity: FragmentActivity) {
|
|
||||||
this.activity = activity
|
|
||||||
fragment = null
|
|
||||||
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
|
||||||
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
|
||||||
}
|
}
|
||||||
|
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||||
constructor(fragment: Fragment) {
|
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||||
this.fragment = fragment
|
|
||||||
activity = null
|
|
||||||
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
|
||||||
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResult(result: TaggedActivityResult) {
|
|
||||||
continuations.remove(result.tag)?.resume(result.isSuccess)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDetails(e: Throwable, url: String?) {
|
fun showDetails(e: Throwable, url: String?) {
|
||||||
ErrorDetailsDialog.show(getFragmentManager(), e, url)
|
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||||
is CloudFlareProtectedException -> resolveCF(e.url, e.headers)
|
is CloudFlareProtectedException -> resolveCF(e)
|
||||||
is AuthRequiredException -> resolveAuthException(e.source)
|
is AuthRequiredException -> resolveAuthException(e.source)
|
||||||
|
is SSLException,
|
||||||
|
is CertPathValidatorException -> {
|
||||||
|
showSslErrorDialog()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
is ProxyConfigException -> {
|
||||||
|
host.withContext {
|
||||||
|
startActivity(SettingsActivity.newProxySettingsIntent(this))
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
is NotFoundException -> {
|
is NotFoundException -> {
|
||||||
openInBrowser(e.url)
|
openInBrowser(e.url)
|
||||||
false
|
false
|
||||||
@@ -67,12 +79,26 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ScrobblerAuthRequiredException -> {
|
||||||
|
val authHelper = scrobblerAuthHelperProvider.get()
|
||||||
|
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
host.withContext {
|
||||||
|
authHelper.startAuth(this, e.scrobbler).onFailure {
|
||||||
|
showDetails(it, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont ->
|
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||||
continuations[CloudFlareActivity.TAG] = cont
|
continuations[CloudFlareActivity.TAG] = cont
|
||||||
cloudflareContract.launch(url to headers)
|
cloudflareContract.launch(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||||
@@ -80,26 +106,68 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
sourceAuthContract.launch(source)
|
sourceAuthContract.launch(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openInBrowser(url: String) {
|
private fun openInBrowser(url: String) = host.withContext {
|
||||||
val context = activity ?: fragment?.activity ?: return
|
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||||
context.startActivity(BrowserActivity.newIntent(context, url, null))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openAlternatives(manga: Manga) {
|
private fun openAlternatives(manga: Manga) = host.withContext {
|
||||||
val context = activity ?: fragment?.activity ?: return
|
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||||
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||||
|
continuations.remove(tag)?.resume(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSslErrorDialog() {
|
||||||
|
val ctx = host.getContext() ?: return
|
||||||
|
if (settings.isSSLBypassEnabled) {
|
||||||
|
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buildAlertDialog(ctx) {
|
||||||
|
setTitle(R.string.ignore_ssl_errors)
|
||||||
|
setMessage(R.string.ignore_ssl_errors_summary)
|
||||||
|
setPositiveButton(R.string.apply) { _, _ ->
|
||||||
|
settings.isSSLBypassEnabled = true
|
||||||
|
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||||
|
ctx.restartApplication()
|
||||||
|
}
|
||||||
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||||
|
getContext()?.apply(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Host : ActivityResultCaller {
|
||||||
|
|
||||||
|
fun getChildFragmentManager(): FragmentManager
|
||||||
|
|
||||||
|
fun getContext(): Context?
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
|
||||||
|
fun create(host: Host): ExceptionResolver
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun getResolveStringId(e: Throwable) = when (e) {
|
fun getResolveStringId(e: Throwable) = when (e) {
|
||||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||||
|
is ScrobblerAuthRequiredException,
|
||||||
is AuthRequiredException -> R.string.sign_in
|
is AuthRequiredException -> R.string.sign_in
|
||||||
|
|
||||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||||
|
is SSLException,
|
||||||
|
is CertPathValidatorException -> R.string.fix
|
||||||
|
|
||||||
|
is ProxyConfigException -> R.string.settings
|
||||||
|
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
package org.koitharu.kotatsu.core.fs
|
package org.koitharu.kotatsu.core.fs
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
|
import androidx.annotation.RequiresApi
|
||||||
|
import org.koitharu.kotatsu.core.util.CloseableSequence
|
||||||
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
class FileSequence(private val dir: File) : Sequence<File> {
|
sealed interface FileSequence : CloseableSequence<File> {
|
||||||
|
|
||||||
override fun iterator(): Iterator<File> {
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
class StreamImpl(dir: File) : FileSequence {
|
||||||
val stream = Files.newDirectoryStream(dir.toPath())
|
|
||||||
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
|
private val stream = Files.newDirectoryStream(dir.toPath())
|
||||||
} else {
|
|
||||||
dir.listFiles().orEmpty().iterator()
|
override fun iterator(): Iterator<File> = MappingIterator(stream.iterator(), Path::toFile)
|
||||||
}
|
|
||||||
|
override fun close() = stream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListImpl(dir: File) : FileSequence {
|
||||||
|
|
||||||
|
private val list = dir.listFiles().orEmpty()
|
||||||
|
|
||||||
|
override fun iterator(): Iterator<File> = list.iterator()
|
||||||
|
|
||||||
|
override fun close() = Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.logs
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.subdir
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
|
|
||||||
private const val DIR = "logs"
|
|
||||||
private const val FLUSH_DELAY = 2_000L
|
|
||||||
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
|
||||||
|
|
||||||
class FileLogger(
|
|
||||||
context: Context,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
name: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val file by lazy {
|
|
||||||
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
|
||||||
File(dir, "$name.log")
|
|
||||||
}
|
|
||||||
val isEnabled: Boolean
|
|
||||||
get() = settings.isLoggingEnabled
|
|
||||||
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
|
|
||||||
private val buffer = ConcurrentLinkedQueue<String>()
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private var flushJob: Job? = null
|
|
||||||
|
|
||||||
fun log(message: String, e: Throwable? = null) {
|
|
||||||
if (!isEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val text = buildString {
|
|
||||||
append(dateTimeFormatter.format(LocalDateTime.now()))
|
|
||||||
append(": ")
|
|
||||||
if (e != null) {
|
|
||||||
append("E!")
|
|
||||||
}
|
|
||||||
append(message)
|
|
||||||
if (e != null) {
|
|
||||||
append(' ')
|
|
||||||
append(e.stackTraceToString())
|
|
||||||
appendLine()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buffer.add(text)
|
|
||||||
postFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun log(messageProducer: () -> String) {
|
|
||||||
if (isEnabled) {
|
|
||||||
log(messageProducer())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun flush() {
|
|
||||||
if (!isEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flushJob?.cancelAndJoin()
|
|
||||||
flushImpl()
|
|
||||||
}
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
fun flushBlocking() {
|
|
||||||
if (!isEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runBlockingSafe { flushJob?.cancelAndJoin() }
|
|
||||||
runBlockingSafe { flushImpl() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postFlush() {
|
|
||||||
if (flushJob?.isActive == true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
|
||||||
delay(FLUSH_DELAY)
|
|
||||||
runCatchingCancellable {
|
|
||||||
flushImpl()
|
|
||||||
}.onFailure {
|
|
||||||
it.printStackTraceDebug()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun flushImpl() = withContext(NonCancellable) {
|
|
||||||
mutex.withLock {
|
|
||||||
if (buffer.isEmpty()) {
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
runInterruptible(Dispatchers.IO) {
|
|
||||||
if (file.length() > MAX_SIZE_BYTES) {
|
|
||||||
rotate()
|
|
||||||
}
|
|
||||||
FileOutputStream(file, true).use {
|
|
||||||
while (true) {
|
|
||||||
val message = buffer.poll() ?: break
|
|
||||||
it.write(message.toByteArray())
|
|
||||||
it.write('\n'.code)
|
|
||||||
}
|
|
||||||
it.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
private fun rotate() {
|
|
||||||
val length = file.length()
|
|
||||||
val bakFile = File(file.parentFile, file.name + ".bak")
|
|
||||||
file.renameTo(bakFile)
|
|
||||||
bakFile.inputStream().use { input ->
|
|
||||||
input.skip(length - MAX_SIZE_BYTES / 2)
|
|
||||||
file.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
output.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bakFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
|
|
||||||
runBlocking(NonCancellable) { block() }
|
|
||||||
} catch (_: InterruptedException) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.logs
|
|
||||||
|
|
||||||
import javax.inject.Qualifier
|
|
||||||
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class TrackerLogger
|
|
||||||
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class SyncLogger
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.logs
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.collection.arraySetOf
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import dagger.multibindings.ElementsIntoSet
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object LoggersModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@TrackerLogger
|
|
||||||
fun provideTrackerLogger(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
settings: AppSettings,
|
|
||||||
) = FileLogger(context, settings, "tracker")
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@SyncLogger
|
|
||||||
fun provideSyncLogger(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
settings: AppSettings,
|
|
||||||
) = FileLogger(context, settings, "sync")
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@ElementsIntoSet
|
|
||||||
fun provideAllLoggers(
|
|
||||||
@TrackerLogger trackerLogger: FileLogger,
|
|
||||||
@SyncLogger syncLogger: FileLogger,
|
|
||||||
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
|
||||||
trackerLogger,
|
|
||||||
syncLogger,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
|
||||||
|
@Deprecated("")
|
||||||
|
enum class GenericSortOrder(
|
||||||
|
@StringRes val titleResId: Int,
|
||||||
|
val ascending: SortOrder,
|
||||||
|
val descending: SortOrder,
|
||||||
|
) {
|
||||||
|
|
||||||
|
UPDATED(R.string.updated, SortOrder.UPDATED_ASC, SortOrder.UPDATED),
|
||||||
|
RATING(R.string.by_rating, SortOrder.RATING_ASC, SortOrder.RATING),
|
||||||
|
POPULARITY(R.string.popularity, SortOrder.POPULARITY_ASC, SortOrder.POPULARITY),
|
||||||
|
DATE(R.string.by_date, SortOrder.NEWEST_ASC, SortOrder.NEWEST),
|
||||||
|
NAME(R.string.by_name, SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL_DESC),
|
||||||
|
;
|
||||||
|
|
||||||
|
operator fun get(direction: SortDirection): SortOrder = when (direction) {
|
||||||
|
SortDirection.ASC -> ascending
|
||||||
|
SortDirection.DESC -> descending
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(order: SortOrder): GenericSortOrder = entries.first { e ->
|
||||||
|
e.ascending == order || e.descending == order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,24 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.MutableObjectIntMap
|
import androidx.collection.MutableObjectIntMap
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.core.text.buildSpannedString
|
||||||
|
import androidx.core.text.strikeThrough
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.util.formatSimple
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import java.text.DecimalFormat
|
|
||||||
import java.text.DecimalFormatSymbols
|
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@JvmName("mangaIds")
|
@JvmName("mangaIds")
|
||||||
@@ -70,6 +73,17 @@ val ContentRating.titleResId: Int
|
|||||||
ContentRating.ADULT -> R.string.rating_adult
|
ContentRating.ADULT -> R.string.rating_adult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val Demographic.titleResId: Int
|
||||||
|
get() = when (this) {
|
||||||
|
Demographic.SHOUNEN -> R.string.demographic_shounen
|
||||||
|
Demographic.SHOUJO -> R.string.demographic_shoujo
|
||||||
|
Demographic.SEINEN -> R.string.demographic_seinen
|
||||||
|
Demographic.JOSEI -> R.string.demographic_josei
|
||||||
|
Demographic.KODOMO -> R.string.demographic_kodomo
|
||||||
|
Demographic.NONE -> R.string.none
|
||||||
|
}
|
||||||
|
|
||||||
fun Manga.findChapter(id: Long): MangaChapter? {
|
fun Manga.findChapter(id: Long): MangaChapter? {
|
||||||
return chapters?.findById(id)
|
return chapters?.findById(id)
|
||||||
}
|
}
|
||||||
@@ -110,7 +124,10 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val Manga.isLocal: Boolean
|
val Manga.isLocal: Boolean
|
||||||
get() = source == MangaSource.LOCAL
|
get() = source == LocalMangaSource
|
||||||
|
|
||||||
|
val Manga.isBroken: Boolean
|
||||||
|
get() = source == UnknownMangaSource
|
||||||
|
|
||||||
val Manga.appUrl: Uri
|
val Manga.appUrl: Uri
|
||||||
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
||||||
@@ -119,17 +136,10 @@ val Manga.appUrl: Uri
|
|||||||
.appendQueryParameter("url", url)
|
.appendQueryParameter("url", url)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val chaptersNumberFormat = DecimalFormat("#.#").also { f ->
|
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
|
||||||
f.decimalFormatSymbols = DecimalFormatSymbols.getInstance().also {
|
number.formatSimple()
|
||||||
it.decimalSeparator = '.'
|
} else {
|
||||||
}
|
null
|
||||||
}
|
|
||||||
|
|
||||||
fun MangaChapter.formatNumber(): String? {
|
|
||||||
if (number <= 0f) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return chaptersNumberFormat.format(number.toDouble())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Manga.chaptersCount(): Int {
|
fun Manga.chaptersCount(): Int {
|
||||||
@@ -147,3 +157,26 @@ fun Manga.chaptersCount(): Int {
|
|||||||
}
|
}
|
||||||
return max
|
return max
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MangaListFilter.getSummary() = buildSpannedString {
|
||||||
|
if (!query.isNullOrEmpty()) {
|
||||||
|
append(query)
|
||||||
|
if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) {
|
||||||
|
append(' ')
|
||||||
|
append('(')
|
||||||
|
appendTagsSummary(this@getSummary)
|
||||||
|
append(')')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
appendTagsSummary(this@getSummary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
|
||||||
|
filter.tags.joinTo(this) { it.title }
|
||||||
|
if (filter.tagsExclude.isNotEmpty()) {
|
||||||
|
strikeThrough {
|
||||||
|
filter.tagsExclude.joinTo(this) { it.title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ data class MangaHistory(
|
|||||||
val page: Int,
|
val page: Int,
|
||||||
val scroll: Int,
|
val scroll: Int,
|
||||||
val percent: Float,
|
val percent: Float,
|
||||||
|
val chaptersCount: Int,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|||||||
@@ -7,26 +7,49 @@ import android.text.style.ForegroundColorSpan
|
|||||||
import android.text.style.RelativeSizeSpan
|
import android.text.style.RelativeSizeSpan
|
||||||
import android.text.style.SuperscriptSpan
|
import android.text.style.SuperscriptSpan
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.text.buildSpannedString
|
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
||||||
import java.util.Locale
|
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
fun MangaSource(name: String): MangaSource {
|
data object LocalMangaSource : MangaSource {
|
||||||
MangaSource.entries.forEach {
|
override val name = "LOCAL"
|
||||||
if (it.name == name) return it
|
|
||||||
}
|
|
||||||
return MangaSource.DUMMY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
|
data object UnknownMangaSource : MangaSource {
|
||||||
|
override val name = "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MangaSource(name: String?): MangaSource {
|
||||||
|
when (name ?: return UnknownMangaSource) {
|
||||||
|
UnknownMangaSource.name -> return UnknownMangaSource
|
||||||
|
|
||||||
|
LocalMangaSource.name -> return LocalMangaSource
|
||||||
|
}
|
||||||
|
if (name.startsWith("content:")) {
|
||||||
|
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
|
||||||
|
return ExternalMangaSource(packageName = parts.first, authority = parts.second)
|
||||||
|
}
|
||||||
|
MangaParserSource.entries.forEach {
|
||||||
|
if (it.name == name) return it
|
||||||
|
}
|
||||||
|
return UnknownMangaSource
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Collection<String>.toMangaSources() = map(::MangaSource)
|
||||||
|
|
||||||
|
fun MangaSource.isNsfw(): Boolean = when (this) {
|
||||||
|
is MangaSourceInfo -> mangaSource.isNsfw()
|
||||||
|
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val ContentType.titleResId
|
val ContentType.titleResId
|
||||||
@@ -35,25 +58,42 @@ val ContentType.titleResId
|
|||||||
ContentType.HENTAI -> R.string.content_type_hentai
|
ContentType.HENTAI -> R.string.content_type_hentai
|
||||||
ContentType.COMICS -> R.string.content_type_comics
|
ContentType.COMICS -> R.string.content_type_comics
|
||||||
ContentType.OTHER -> R.string.content_type_other
|
ContentType.OTHER -> R.string.content_type_other
|
||||||
|
ContentType.MANHWA -> R.string.content_type_manhwa
|
||||||
|
ContentType.MANHUA -> R.string.content_type_manhua
|
||||||
|
ContentType.NOVEL -> R.string.content_type_novel
|
||||||
|
ContentType.ONE_SHOT -> R.string.content_type_one_shot
|
||||||
|
ContentType.DOUJINSHI -> R.string.content_type_doujinshi
|
||||||
|
ContentType.IMAGE_SET -> R.string.content_type_image_set
|
||||||
|
ContentType.ARTIST_CG -> R.string.content_type_artist_cg
|
||||||
|
ContentType.GAME_CG -> R.string.content_type_game_cg
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.getSummary(context: Context): String {
|
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
|
||||||
val type = context.getString(contentType.titleResId)
|
mangaSource.unwrap()
|
||||||
val locale = locale?.toLocale().getDisplayName(context)
|
|
||||||
return context.getString(R.string.source_summary_pattern, type, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
|
|
||||||
buildSpannedString {
|
|
||||||
append(title)
|
|
||||||
append(' ')
|
|
||||||
appendNsfwLabel(context)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
title
|
this
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
|
||||||
|
is MangaParserSource -> {
|
||||||
|
val type = context.getString(source.contentType.titleResId)
|
||||||
|
val locale = source.locale.toLocale().getDisplayName(context)
|
||||||
|
context.getString(R.string.source_summary_pattern, type, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ExternalMangaSource -> context.getString(R.string.external_source)
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
||||||
|
is MangaParserSource -> source.title
|
||||||
|
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||||
|
is ExternalMangaSource -> source.resolveName(context)
|
||||||
|
else -> context.getString(R.string.unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
||||||
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
|
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
|
||||||
RelativeSizeSpan(0.74f),
|
RelativeSizeSpan(0.74f),
|
||||||
SuperscriptSpan(),
|
SuperscriptSpan(),
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
data class MangaSourceInfo(
|
||||||
|
val mangaSource: MangaSource,
|
||||||
|
val isEnabled: Boolean,
|
||||||
|
val isPinned: Boolean,
|
||||||
|
) : MangaSource by mangaSource
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
enum class SortDirection {
|
||||||
|
|
||||||
|
ASC, DESC;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model.parcelable
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
|
import kotlinx.parcelize.Parceler
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class MangaSourceParceler : Parceler<MangaSource> {
|
||||||
|
|
||||||
|
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
|
||||||
|
|
||||||
|
override fun MangaSource.write(parcel: Parcel, flags: Int) {
|
||||||
|
parcel.writeString(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,8 @@ import android.os.Parcel
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class ParcelableChapter(
|
data class ParcelableChapter(
|
||||||
@@ -25,8 +24,8 @@ data class ParcelableChapter(
|
|||||||
scanlator = parcel.readString(),
|
scanlator = parcel.readString(),
|
||||||
uploadDate = parcel.readLong(),
|
uploadDate = parcel.readLong(),
|
||||||
branch = parcel.readString(),
|
branch = parcel.readString(),
|
||||||
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY,
|
source = MangaSource(parcel.readString()),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
||||||
@@ -38,7 +37,7 @@ data class ParcelableChapter(
|
|||||||
parcel.writeString(scanlator)
|
parcel.writeString(scanlator)
|
||||||
parcel.writeLong(uploadDate)
|
parcel.writeLong(uploadDate)
|
||||||
parcel.writeString(branch)
|
parcel.writeString(branch)
|
||||||
parcel.writeSerializable(source)
|
parcel.writeString(source.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.os.Parcelable
|
|||||||
import androidx.core.os.ParcelCompat
|
import androidx.core.os.ParcelCompat
|
||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -30,7 +31,7 @@ data class ParcelableManga(
|
|||||||
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||||
parcel.writeSerializable(state)
|
parcel.writeSerializable(state)
|
||||||
parcel.writeString(author)
|
parcel.writeString(author)
|
||||||
parcel.writeSerializable(source)
|
parcel.writeString(source.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun create(parcel: Parcel) = ParcelableManga(
|
override fun create(parcel: Parcel) = ParcelableManga(
|
||||||
@@ -49,8 +50,8 @@ data class ParcelableManga(
|
|||||||
state = parcel.readSerializableCompat(),
|
state = parcel.readSerializableCompat(),
|
||||||
author = parcel.readString(),
|
author = parcel.readString(),
|
||||||
chapters = null,
|
chapters = null,
|
||||||
source = requireNotNull(parcel.readSerializableCompat()),
|
source = MangaSource(parcel.readString()),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model.parcelable
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parceler
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.parcelize.TypeParceler
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.readEnumSet
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.writeEnumSet
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
|
||||||
|
object MangaListFilterParceler : Parceler<MangaListFilter> {
|
||||||
|
|
||||||
|
override fun MangaListFilter.write(parcel: Parcel, flags: Int) {
|
||||||
|
parcel.writeString(query)
|
||||||
|
parcel.writeParcelable(ParcelableMangaTags(tags), 0)
|
||||||
|
parcel.writeParcelable(ParcelableMangaTags(tagsExclude), 0)
|
||||||
|
parcel.writeSerializable(locale)
|
||||||
|
parcel.writeSerializable(originalLocale)
|
||||||
|
parcel.writeEnumSet(states)
|
||||||
|
parcel.writeEnumSet(contentRating)
|
||||||
|
parcel.writeEnumSet(types)
|
||||||
|
parcel.writeEnumSet(demographics)
|
||||||
|
parcel.writeInt(year)
|
||||||
|
parcel.writeInt(yearFrom)
|
||||||
|
parcel.writeInt(yearTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun create(parcel: Parcel) = MangaListFilter(
|
||||||
|
query = parcel.readString(),
|
||||||
|
tags = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
|
||||||
|
tagsExclude = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
|
||||||
|
locale = parcel.readSerializableCompat(),
|
||||||
|
originalLocale = parcel.readSerializableCompat(),
|
||||||
|
states = parcel.readEnumSet<MangaState>().orEmpty(),
|
||||||
|
contentRating = parcel.readEnumSet<ContentRating>().orEmpty(),
|
||||||
|
types = parcel.readEnumSet<ContentType>().orEmpty(),
|
||||||
|
demographics = parcel.readEnumSet<Demographic>().orEmpty(),
|
||||||
|
year = parcel.readInt(),
|
||||||
|
yearFrom = parcel.readInt(),
|
||||||
|
yearTo = parcel.readInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
@TypeParceler<MangaListFilter, MangaListFilterParceler>
|
||||||
|
data class ParcelableMangaListFilter(val filter: MangaListFilter) : Parcelable
|
||||||
@@ -5,7 +5,7 @@ import android.os.Parcelable
|
|||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.parcelize.TypeParceler
|
import kotlinx.parcelize.TypeParceler
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
|
||||||
object MangaPageParceler : Parceler<MangaPage> {
|
object MangaPageParceler : Parceler<MangaPage> {
|
||||||
@@ -13,14 +13,14 @@ object MangaPageParceler : Parceler<MangaPage> {
|
|||||||
id = parcel.readLong(),
|
id = parcel.readLong(),
|
||||||
url = requireNotNull(parcel.readString()),
|
url = requireNotNull(parcel.readString()),
|
||||||
preview = parcel.readString(),
|
preview = parcel.readString(),
|
||||||
source = requireNotNull(parcel.readSerializableCompat()),
|
source = MangaSource(parcel.readString()),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun MangaPage.write(parcel: Parcel, flags: Int) {
|
override fun MangaPage.write(parcel: Parcel, flags: Int) {
|
||||||
parcel.writeLong(id)
|
parcel.writeLong(id)
|
||||||
parcel.writeString(url)
|
parcel.writeString(url)
|
||||||
parcel.writeString(preview)
|
parcel.writeString(preview)
|
||||||
parcel.writeSerializable(source)
|
parcel.writeString(source.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ import android.os.Parcelable
|
|||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.parcelize.TypeParceler
|
import kotlinx.parcelize.TypeParceler
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
|
||||||
object MangaTagParceler : Parceler<MangaTag> {
|
object MangaTagParceler : Parceler<MangaTag> {
|
||||||
override fun create(parcel: Parcel) = MangaTag(
|
override fun create(parcel: Parcel) = MangaTag(
|
||||||
title = requireNotNull(parcel.readString()),
|
title = requireNotNull(parcel.readString()),
|
||||||
key = requireNotNull(parcel.readString()),
|
key = requireNotNull(parcel.readString()),
|
||||||
source = requireNotNull(parcel.readSerializableCompat()),
|
source = MangaSource(parcel.readString()),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun MangaTag.write(parcel: Parcel, flags: Int) {
|
override fun MangaTag.write(parcel: Parcel, flags: Int) {
|
||||||
parcel.writeString(title)
|
parcel.writeString(title)
|
||||||
parcel.writeString(key)
|
parcel.writeString(key)
|
||||||
parcel.writeSerializable(source)
|
parcel.writeString(source.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import java.io.IOException
|
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.net.ProxySelector
|
import java.net.ProxySelector
|
||||||
@@ -31,9 +32,12 @@ class AppProxySelector(
|
|||||||
val type = settings.proxyType
|
val type = settings.proxyType
|
||||||
val address = settings.proxyAddress
|
val address = settings.proxyAddress
|
||||||
val port = settings.proxyPort
|
val port = settings.proxyPort
|
||||||
if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) {
|
if (type == Proxy.Type.DIRECT) {
|
||||||
return Proxy.NO_PROXY
|
return Proxy.NO_PROXY
|
||||||
}
|
}
|
||||||
|
if (address.isNullOrEmpty() || port == 0) {
|
||||||
|
throw ProxyConfigException()
|
||||||
|
}
|
||||||
cachedProxy?.let {
|
cachedProxy?.let {
|
||||||
val addr = it.address() as? InetSocketAddress
|
val addr = it.address() as? InetSocketAddress
|
||||||
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
||||||
|
|||||||
@@ -2,31 +2,43 @@ package org.koitharu.kotatsu.core.network
|
|||||||
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.closeQuietly
|
import okio.IOException
|
||||||
import org.jsoup.Jsoup
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
|
|
||||||
|
|
||||||
class CloudFlareInterceptor : Interceptor {
|
class CloudFlareInterceptor : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val response = chain.proceed(chain.request())
|
val request = chain.request()
|
||||||
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
val response = chain.proceed(request)
|
||||||
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
|
return when (CloudFlareHelper.checkResponseForProtection(response)) {
|
||||||
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
|
CloudFlareHelper.PROTECTION_BLOCKED -> response.closeThrowing(
|
||||||
} ?: return response
|
CloudFlareBlockedException(
|
||||||
if (content.getElementById("challenge-error-title") != null) {
|
url = request.url.toString(),
|
||||||
val request = response.request
|
source = request.tag(MangaSource::class.java),
|
||||||
response.closeQuietly()
|
),
|
||||||
throw CloudFlareProtectedException(
|
)
|
||||||
|
|
||||||
|
CloudFlareHelper.PROTECTION_CAPTCHA -> response.closeThrowing(
|
||||||
|
CloudFlareProtectedException(
|
||||||
url = request.url.toString(),
|
url = request.url.toString(),
|
||||||
source = request.tag(MangaSource::class.java),
|
source = request.tag(MangaSource::class.java),
|
||||||
headers = request.headers,
|
headers = request.headers,
|
||||||
)
|
),
|
||||||
}
|
)
|
||||||
|
|
||||||
|
else -> response
|
||||||
}
|
}
|
||||||
return response
|
}
|
||||||
|
|
||||||
|
private fun Response.closeThrowing(error: IOException): Nothing {
|
||||||
|
try {
|
||||||
|
close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error.addSuppressed(e)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ import android.util.Log
|
|||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Interceptor.Chain
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import okio.IOException
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import java.net.IDN
|
import java.net.IDN
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -23,11 +26,11 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
|
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
val source = request.tag(MangaSource::class.java)
|
val source = request.tag(MangaSource::class.java)
|
||||||
val repository = if (source != null) {
|
val repository = if (source != null) {
|
||||||
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
|
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||||
} else {
|
} else {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Log.w("Http", "Request without source tag: ${request.url}")
|
Log.w("Http", "Request without source tag: ${request.url}")
|
||||||
@@ -35,7 +38,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
val headersBuilder = request.headers.newBuilder()
|
val headersBuilder = request.headers.newBuilder()
|
||||||
repository?.headers?.let {
|
repository?.getRequestHeaders()?.let {
|
||||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||||
}
|
}
|
||||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||||
@@ -46,7 +49,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
|
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
|
||||||
}
|
}
|
||||||
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
||||||
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
return repository?.interceptSafe(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Headers.Builder.trySet(name: String, value: String) = try {
|
private fun Headers.Builder.trySet(name: String, value: String) = try {
|
||||||
@@ -55,10 +58,21 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
|
||||||
|
intercept(chain)
|
||||||
|
}.getOrElse { e ->
|
||||||
|
if (e is IOException) {
|
||||||
|
throw e
|
||||||
|
} else {
|
||||||
|
// only IOException can be safely thrown from an Interceptor
|
||||||
|
throw IOException("Error in interceptor: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class ProxyChain(
|
private class ProxyChain(
|
||||||
private val delegate: Interceptor.Chain,
|
private val delegate: Chain,
|
||||||
private val request: Request,
|
private val request: Request,
|
||||||
) : Interceptor.Chain by delegate {
|
) : Chain by delegate {
|
||||||
|
|
||||||
override fun request(): Request = request
|
override fun request(): Request = request
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ class DoHManager(
|
|||||||
tryGetByIp("2a10:50c0::2:ff"),
|
tryGetByIp("2a10:50c0::2:ff"),
|
||||||
),
|
),
|
||||||
).build()
|
).build()
|
||||||
|
|
||||||
|
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||||
|
.url("https://2ca4h4crra.cloudflare-gateway.com/dns-query".toHttpUrl())
|
||||||
|
.resolvePublicAddresses(true)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryGetByIp(ip: String): InetAddress? = try {
|
private fun tryGetByIp(ip: String): InetAddress? = try {
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.network
|
|||||||
|
|
||||||
enum class DoHProvider {
|
enum class DoHProvider {
|
||||||
|
|
||||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD
|
NONE, GOOGLE, CLOUDFLARE, ADGUARD, ZERO_MS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import coil.intercept.Interceptor
|
|
||||||
import coil.request.ErrorResult
|
|
||||||
import coil.request.ImageResult
|
|
||||||
import coil.request.SuccessResult
|
|
||||||
import coil.size.Dimension
|
|
||||||
import coil.size.isOriginal
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import java.util.Collections
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ImageProxyInterceptor @Inject constructor(
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) : Interceptor {
|
|
||||||
|
|
||||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
|
||||||
|
|
||||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
|
||||||
val request = chain.request
|
|
||||||
if (!settings.isImagesProxyEnabled) {
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
val url: HttpUrl? = when (val data = request.data) {
|
|
||||||
is HttpUrl -> data
|
|
||||||
is String -> data.toHttpUrlOrNull()
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
val newUrl = HttpUrl.Builder()
|
|
||||||
.scheme("https")
|
|
||||||
.host("wsrv.nl")
|
|
||||||
.addQueryParameter("url", url.toString())
|
|
||||||
.addQueryParameter("we", null)
|
|
||||||
val size = request.sizeResolver.size()
|
|
||||||
if (!size.isOriginal) {
|
|
||||||
newUrl.addQueryParameter("crop", "cover")
|
|
||||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
|
||||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val newRequest = request.newBuilder()
|
|
||||||
.data(newUrl.build())
|
|
||||||
.build()
|
|
||||||
val result = chain.proceed(newRequest)
|
|
||||||
return if (result is SuccessResult) {
|
|
||||||
result
|
|
||||||
} else {
|
|
||||||
logDebug((result as? ErrorResult)?.throwable)
|
|
||||||
chain.proceed(request).also {
|
|
||||||
if (it is SuccessResult) {
|
|
||||||
blacklist.add(url.host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
|
||||||
if (!settings.isImagesProxyEnabled) {
|
|
||||||
return okHttp.newCall(request).await()
|
|
||||||
}
|
|
||||||
val sourceUrl = request.url
|
|
||||||
val targetUrl = HttpUrl.Builder()
|
|
||||||
.scheme("https")
|
|
||||||
.host("wsrv.nl")
|
|
||||||
.addQueryParameter("url", sourceUrl.toString())
|
|
||||||
.addQueryParameter("we", null)
|
|
||||||
val newRequest = request.newBuilder()
|
|
||||||
.url(targetUrl.build())
|
|
||||||
.build()
|
|
||||||
return runCatchingCancellable {
|
|
||||||
okHttp.doCall(newRequest)
|
|
||||||
}.recover {
|
|
||||||
logDebug(it)
|
|
||||||
okHttp.doCall(request).also {
|
|
||||||
blacklist.add(sourceUrl.host)
|
|
||||||
}
|
|
||||||
}.getOrThrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
|
||||||
return newCall(request).await().ensureSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logDebug(e: Throwable?) {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Log.w("ImageProxy", e.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,8 +13,9 @@ import okhttp3.internal.canParseAsIpAddress
|
|||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import java.util.EnumMap
|
import java.util.EnumMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -26,8 +27,8 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java)
|
private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
|
||||||
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java)
|
private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java)
|
||||||
|
|
||||||
val isEnabled: Boolean
|
val isEnabled: Boolean
|
||||||
get() = settings.isMirrorSwitchingAvailable
|
get() = settings.isMirrorSwitchingAvailable
|
||||||
@@ -53,7 +54,7 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
|
suspend fun trySwitchMirror(repository: ParserMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
return@runInterruptible false
|
return@runInterruptible false
|
||||||
}
|
}
|
||||||
@@ -75,14 +76,14 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
|
fun rollback(repository: ParserMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
|
||||||
blacklist[repository.source]?.remove(oldMirror)
|
blacklist[repository.source]?.remove(oldMirror)
|
||||||
repository.domain = oldMirror
|
repository.domain = oldMirror
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
|
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
|
||||||
val source = request.tag(MangaSource::class.java) ?: return null
|
val source = request.tag(MangaSource::class.java) ?: return null
|
||||||
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
|
val repository = mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository ?: return null
|
||||||
val mirrors = repository.getAvailableMirrors()
|
val mirrors = repository.getAvailableMirrors()
|
||||||
if (mirrors.isEmpty()) {
|
if (mirrors.isEmpty()) {
|
||||||
return null
|
return null
|
||||||
@@ -93,7 +94,7 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun tryMirrors(
|
private fun tryMirrors(
|
||||||
repository: RemoteMangaRepository,
|
repository: ParserMangaRepository,
|
||||||
mirrors: List<String>,
|
mirrors: List<String>,
|
||||||
chain: Interceptor.Chain,
|
chain: Interceptor.Chain,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -145,15 +146,15 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
return source().readByteArray().toResponseBody(contentType())
|
return source().readByteArray().toResponseBody(contentType())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) {
|
private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) {
|
||||||
Any()
|
Any()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isBlacklisted(source: MangaSource, domain: String): Boolean {
|
private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean {
|
||||||
return blacklist[source]?.contains(domain) == true
|
return blacklist[source]?.contains(domain) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addToBlacklist(source: MangaSource, domain: String) {
|
private fun addToBlacklist(source: MangaParserSource, domain: String) {
|
||||||
blacklist.getOrPut(source) {
|
blacklist.getOrPut(source) {
|
||||||
ArraySet(2)
|
ArraySet(2)
|
||||||
}.add(domain)
|
}.add(domain)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AndroidRuntimeException
|
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -15,9 +14,14 @@ import org.koitharu.kotatsu.BuildConfig
|
|||||||
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
||||||
|
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||||
|
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Provider
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -27,15 +31,19 @@ interface NetworkModule {
|
|||||||
@Binds
|
@Binds
|
||||||
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
|
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
fun bindImageProxyInterceptor(impl: RealImageProxyInterceptor): ImageProxyInterceptor
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideCookieJar(
|
fun provideCookieJar(
|
||||||
@ApplicationContext context: Context
|
@ApplicationContext context: Context
|
||||||
): MutableCookieJar = try {
|
): MutableCookieJar = runCatching {
|
||||||
AndroidCookieJar()
|
AndroidCookieJar()
|
||||||
} catch (e: AndroidRuntimeException) {
|
}.getOrElse { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
// WebView is not available
|
// WebView is not available
|
||||||
PreferencesCookieJar(context)
|
PreferencesCookieJar(context)
|
||||||
}
|
}
|
||||||
@@ -50,10 +58,12 @@ interface NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
@BaseHttpClient
|
@BaseHttpClient
|
||||||
fun provideBaseHttpClient(
|
fun provideBaseHttpClient(
|
||||||
|
@ApplicationContext contextProvider: Provider<Context>,
|
||||||
cache: Cache,
|
cache: Cache,
|
||||||
cookieJar: CookieJar,
|
cookieJar: CookieJar,
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
): OkHttpClient = OkHttpClient.Builder().apply {
|
): OkHttpClient = OkHttpClient.Builder().apply {
|
||||||
|
assertNotInMainThread()
|
||||||
connectTimeout(20, TimeUnit.SECONDS)
|
connectTimeout(20, TimeUnit.SECONDS)
|
||||||
readTimeout(60, TimeUnit.SECONDS)
|
readTimeout(60, TimeUnit.SECONDS)
|
||||||
writeTimeout(20, TimeUnit.SECONDS)
|
writeTimeout(20, TimeUnit.SECONDS)
|
||||||
@@ -62,7 +72,9 @@ interface NetworkModule {
|
|||||||
proxyAuthenticator(ProxyAuthenticator(settings))
|
proxyAuthenticator(ProxyAuthenticator(settings))
|
||||||
dns(DoHManager(cache, settings))
|
dns(DoHManager(cache, settings))
|
||||||
if (settings.isSSLBypassEnabled) {
|
if (settings.isSSLBypassEnabled) {
|
||||||
bypassSSLErrors()
|
disableCertificateVerification()
|
||||||
|
} else {
|
||||||
|
installExtraCertificates(contextProvider.get())
|
||||||
}
|
}
|
||||||
cache(cache)
|
cache(cache)
|
||||||
addInterceptor(GZipInterceptor())
|
addInterceptor(GZipInterceptor())
|
||||||
|
|||||||
@@ -3,28 +3,27 @@ package org.koitharu.kotatsu.core.network
|
|||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class RateLimitInterceptor : Interceptor {
|
class RateLimitInterceptor : Interceptor {
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val response = chain.proceed(chain.request())
|
val response = chain.proceed(chain.request())
|
||||||
if (response.code == 429) {
|
if (response.code == 429) {
|
||||||
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
|
|
||||||
val request = response.request
|
val request = response.request
|
||||||
response.closeQuietly()
|
response.closeQuietly()
|
||||||
throw TooManyRequestExceptions(
|
throw TooManyRequestExceptions(
|
||||||
url = request.url.toString(),
|
url = request.url.toString(),
|
||||||
retryAt = retryDate,
|
retryAfter = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter() ?: 0L,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.parseRetryDate(): Instant? {
|
private fun String.parseRetryAfter(): Long {
|
||||||
return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
|
return toLongOrNull()?.let { TimeUnit.SECONDS.toMillis(it) }
|
||||||
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
|
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user