Compare commits
598 Commits
v6.6.2
...
ui_playgro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e4f18066 | ||
|
|
b1ab48e912 | ||
|
|
a71e2dd289 | ||
|
|
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 | ||
|
|
cae07b2798 | ||
|
|
b14603c384 | ||
|
|
2f21d0f0f8 | ||
|
|
7e182cb0ad | ||
|
|
f79d2cb733 | ||
|
|
ce296900c5 | ||
|
|
0156ae86eb | ||
|
|
efd82b6d96 | ||
|
|
b4371d2cd2 | ||
|
|
676c94d759 | ||
|
|
b4c8fb7f9b | ||
|
|
5f79d37506 | ||
|
|
2e074573c0 | ||
|
|
82281312fb | ||
|
|
ed6a906459 | ||
|
|
00b01f298d | ||
|
|
aa99ea1245 | ||
|
|
732c614aad | ||
|
|
afe16859d4 | ||
|
|
c95f2aa9a1 | ||
|
|
630cece4f5 | ||
|
|
f0101bc183 | ||
|
|
7c2829226d | ||
|
|
83bd390c2a | ||
|
|
b090652007 | ||
|
|
569edd91c9 | ||
|
|
2e81684652 | ||
|
|
2573d150f9 | ||
|
|
24fe83aa5c | ||
|
|
bbc39becc3 | ||
|
|
65077c1fba | ||
|
|
bec0ce2c96 | ||
|
|
256f0a31bc | ||
|
|
b8e48d8b8a | ||
|
|
8313d6966f | ||
|
|
7e581a5ed7 | ||
|
|
16027e3295 | ||
|
|
e4e14214d9 | ||
|
|
e40a39ca28 | ||
|
|
82e711d619 | ||
|
|
8c2bff78f7 | ||
|
|
4f2c38d4ee | ||
|
|
3c54fe4217 | ||
|
|
750bf11fdc | ||
|
|
b2c5ec5082 | ||
|
|
f97d4d452f | ||
|
|
640fe272c8 | ||
|
|
f730e80bb7 | ||
|
|
d975e92991 | ||
|
|
0d29190bd1 | ||
|
|
76c07b1567 | ||
|
|
55c82a6f5c | ||
|
|
f81d298315 | ||
|
|
fd17e1ea20 | ||
|
|
a1dc401eee | ||
|
|
b5ee465cde | ||
|
|
956b04e974 | ||
|
|
f65c213e2d | ||
|
|
813ce2e195 | ||
|
|
0eb320ec76 | ||
|
|
b17aa6c031 | ||
|
|
97b5102e6c | ||
|
|
fe408c0832 | ||
|
|
4f3d1a9814 | ||
|
|
65abfc3a49 | ||
|
|
ba88ca8234 | ||
|
|
63470db6f5 | ||
|
|
1e39ae48ec | ||
|
|
6fcc45d554 | ||
|
|
094b0f694c | ||
|
|
5d1a2fcf77 | ||
|
|
876675445d | ||
|
|
51362e6cce | ||
|
|
f7a70680bd | ||
|
|
8e82db441c | ||
|
|
f2626c668d | ||
|
|
4694215ccc | ||
|
|
096f5b15dc | ||
|
|
101d357eff | ||
|
|
11cd5609bb | ||
|
|
fda59996aa | ||
|
|
20461112d2 | ||
|
|
f98bb87d6e | ||
|
|
c451952a1e | ||
|
|
f8cbc9692f | ||
|
|
9f3113363b | ||
|
|
dba36838d4 | ||
|
|
f6de1b02d7 | ||
|
|
d6b8e2fd9e | ||
|
|
5227240478 | ||
|
|
8f65ea6535 | ||
|
|
7d7a6eadd2 | ||
|
|
40f1ad3181 | ||
|
|
a28c9447d7 | ||
|
|
a84cf97982 | ||
|
|
3a8eb58fd1 | ||
|
|
5d75e9af4a | ||
|
|
d4684e7462 | ||
|
|
c0a2f0b533 | ||
|
|
40867dd2b6 | ||
|
|
c3294e6459 | ||
|
|
5139feb51a | ||
|
|
6b1240fccb | ||
|
|
e00a5b7505 | ||
|
|
2c07d2c8e1 | ||
|
|
45c3c05f01 | ||
|
|
e97a745713 | ||
|
|
2dc4de0a3c | ||
|
|
3cf2c58058 | ||
|
|
1e19f32fc5 | ||
|
|
99e4359523 | ||
|
|
04868488cc | ||
|
|
2b3b406b84 | ||
|
|
7ab3c75232 | ||
|
|
61f7755465 | ||
|
|
9389015ab9 | ||
|
|
bc56a94aa6 | ||
|
|
7cfcaec6dd | ||
|
|
39c7ae31cd | ||
|
|
9349eccc0c | ||
|
|
8204934359 | ||
|
|
b5497c571e | ||
|
|
35a2ac4b04 | ||
|
|
b4d52f1367 | ||
|
|
81d4a3cf68 | ||
|
|
c2e30b3009 | ||
|
|
0c823f1056 | ||
|
|
44adbde536 | ||
|
|
ae0b405ba5 | ||
|
|
325a8be484 | ||
|
|
f39ccb6223 | ||
|
|
6cb6c891dd | ||
|
|
8cc04b0f7a | ||
|
|
258dbf3dc3 | ||
|
|
e7af4e8450 | ||
|
|
0c25c61858 | ||
|
|
abc3e45907 | ||
|
|
bd98d8eded | ||
|
|
2e81f41073 | ||
|
|
5cccebc416 | ||
|
|
c668ffd555 | ||
|
|
a0f77b715f | ||
|
|
2831843a25 | ||
|
|
86c1aa11b0 | ||
|
|
d71514ec7a | ||
|
|
92ed320f57 | ||
|
|
2de1fe8b77 | ||
|
|
cebc3cd9e8 | ||
|
|
6c0e2e2b90 | ||
|
|
b4bd923ce8 | ||
|
|
813561fd3b | ||
|
|
4107336132 | ||
|
|
30d9d87c17 | ||
|
|
c4b5be657d | ||
|
|
8a763b2b9f | ||
|
|
c783378022 | ||
|
|
c4355f16e8 | ||
|
|
522dfc2418 | ||
|
|
06d03e3ddd | ||
|
|
9dc8c7959d | ||
|
|
db219020ca | ||
|
|
c04edcb76c | ||
|
|
936fc2e4ae | ||
|
|
cbed866665 | ||
|
|
ac568b6361 | ||
|
|
84157f988d | ||
|
|
6f6339f0f8 | ||
|
|
a7019b9096 | ||
|
|
867e3f10ca | ||
|
|
fb2cf04d75 | ||
|
|
3ed44ba0d6 | ||
|
|
b78104a0f1 | ||
|
|
e4ee93f77c | ||
|
|
c6e8da5f23 | ||
|
|
376de7cce3 | ||
|
|
bec2195971 | ||
|
|
722ac4ecc7 | ||
|
|
516c1c02a6 | ||
|
|
0cb7e71781 | ||
|
|
36a74f32df | ||
|
|
0e4ef32642 | ||
|
|
3125cac4c8 | ||
|
|
5d9016d1bc | ||
|
|
c5eeb89d10 | ||
|
|
4f8f43cab1 | ||
|
|
4cbff308ce | ||
|
|
d786ab7deb | ||
|
|
c823d402ff | ||
|
|
12e68db41f | ||
|
|
96717321d2 | ||
|
|
044b5590ef | ||
|
|
00112ebb44 | ||
|
|
00dde80fdf | ||
|
|
f1dfc4ebd6 | ||
|
|
5426edd83a | ||
|
|
2a500eb2cb | ||
|
|
2310ed06c1 | ||
|
|
68ed7a09d6 | ||
|
|
6cdb56e740 | ||
|
|
35fb78c924 | ||
|
|
8c3b5d7f53 | ||
|
|
af6592a8df | ||
|
|
9efb82d887 | ||
|
|
f8722ddc73 | ||
|
|
656ac97153 | ||
|
|
4fc23f8f54 | ||
|
|
e1f325993f | ||
|
|
4c3938a1fd | ||
|
|
530dfa8cde | ||
|
|
58d1c3de26 | ||
|
|
ba2ed6a2ef | ||
|
|
2d909854fb | ||
|
|
cba694bedd | ||
|
|
e5cf1be91a | ||
|
|
72a1dd8227 | ||
|
|
8558b00dca | ||
|
|
8e9175d5f0 | ||
|
|
eae40d9b90 | ||
|
|
2d61209696 | ||
|
|
d24754f2a0 | ||
|
|
54ef02ad88 | ||
|
|
e2a82920b6 | ||
|
|
d494030d50 | ||
|
|
73369f9a6d | ||
|
|
cc1da6e8da | ||
|
|
668a5bd040 | ||
|
|
8efa8bc0d2 | ||
|
|
6e6c70a770 | ||
|
|
413605b520 | ||
|
|
bdf23a0d62 | ||
|
|
4c5d26d4b4 | ||
|
|
3b7ad7f28d | ||
|
|
331af45a29 | ||
|
|
d349bd30c9 | ||
|
|
3349e3abc5 | ||
|
|
4aa31ead67 | ||
|
|
113da3b6c1 | ||
|
|
8b027e2f45 | ||
|
|
9c462b1a3a | ||
|
|
a5bc8c1e9e | ||
|
|
ebb77c68cc | ||
|
|
74ddf86ebe | ||
|
|
12d2fdaf3e | ||
|
|
8cfc97c795 | ||
|
|
3855ca802e | ||
|
|
9db427275f | ||
|
|
3a38644089 | ||
|
|
60a34ec092 | ||
|
|
acd79f12e3 | ||
|
|
461d7ed578 | ||
|
|
5374ac390c | ||
|
|
913a67a652 | ||
|
|
e7a920e43a | ||
|
|
9668b3ef5f | ||
|
|
9581f937de | ||
|
|
44ef6f6dbf | ||
|
|
af11697133 | ||
|
|
09ff356790 | ||
|
|
92ea50d6b6 | ||
|
|
077107e9a7 | ||
|
|
ae57561591 | ||
|
|
2379efc191 | ||
|
|
edca0e5334 | ||
|
|
a4e2675d61 | ||
|
|
892f95a7a6 | ||
|
|
95aaa967a8 | ||
|
|
5687ca6e96 | ||
|
|
d0ee185d2e | ||
|
|
21a3ac0902 | ||
|
|
1382ab7933 | ||
|
|
aabdd281f3 | ||
|
|
131a0ffcaa | ||
|
|
4194609929 | ||
|
|
889b799d8d | ||
|
|
6f7f3dc5e2 | ||
|
|
72187e7da0 | ||
|
|
f881cc439a | ||
|
|
ccdebf6789 | ||
|
|
4252ebd24d | ||
|
|
4db61d3c04 | ||
|
|
cd0575a524 | ||
|
|
6eb2608f88 | ||
|
|
39e21ff93c | ||
|
|
5ec2eab6b8 | ||
|
|
850f6c2f3e | ||
|
|
ec53eb9c70 | ||
|
|
cdd76f723f | ||
|
|
e7c9d1943d | ||
|
|
b1240e7efa | ||
|
|
a0a72b1192 | ||
|
|
5d9a59d577 | ||
|
|
83cb35fe6e | ||
|
|
0fff53ae47 | ||
|
|
a95017a5f0 | ||
|
|
9251823d9a | ||
|
|
ce8f87272b | ||
|
|
db1ddf539c | ||
|
|
d56fc674ab | ||
|
|
a37e8825b0 | ||
|
|
c9fcc0f0f8 | ||
|
|
2450544454 | ||
|
|
f6a510653e | ||
|
|
5990da587c | ||
|
|
91e3d2f5db | ||
|
|
971c683746 | ||
|
|
15e9aaab26 | ||
|
|
da2ad40adf | ||
|
|
af5716a8ce | ||
|
|
a98202e15e | ||
|
|
d6887e2d75 | ||
|
|
ba6afd44dd | ||
|
|
0b55c4d037 | ||
|
|
2a5300a634 | ||
|
|
59bfa929fd | ||
|
|
c5d88f8700 | ||
|
|
a1120ea709 | ||
|
|
796af6b811 | ||
|
|
eafd878413 | ||
|
|
9baf2bfcd9 | ||
|
|
0b4dd5beef | ||
|
|
12047a85c7 | ||
|
|
48808f8a7d | ||
|
|
18e573b6b8 | ||
|
|
c80dc08d6c | ||
|
|
61b5b8aa73 | ||
|
|
5e79809326 | ||
|
|
dcbd7c2117 | ||
|
|
1e134b109a | ||
|
|
7f9b6a67af | ||
|
|
79448bb01d | ||
|
|
b94f9e4b01 | ||
|
|
ae8b48d733 | ||
|
|
313013dccd | ||
|
|
c36d23ec06 | ||
|
|
ebe71476d1 | ||
|
|
ca2ae9bc83 | ||
|
|
f2898aba85 | ||
|
|
1b73f19ae1 | ||
|
|
4b4aea0410 | ||
|
|
b9f8e3978a | ||
|
|
89eed10508 | ||
|
|
799c0910ea | ||
|
|
ed6802344a | ||
|
|
2d94688742 | ||
|
|
d0b44050f5 | ||
|
|
486ae12d41 | ||
|
|
cb36f085d7 | ||
|
|
5af42e5b6b | ||
|
|
160fa2c001 | ||
|
|
f9ba87b8cf | ||
|
|
ee3cf08545 | ||
|
|
e0497b357b | ||
|
|
6de08cd4fe | ||
|
|
4760f1ea35 | ||
|
|
c64c4643bf | ||
|
|
7bfcdb387c | ||
|
|
503fcf65fb | ||
|
|
54fb79dc98 | ||
|
|
ea4c048029 | ||
|
|
badc826cd3 | ||
|
|
fd01367601 | ||
|
|
cb64740349 | ||
|
|
6fdcaf0d02 | ||
|
|
56de725cf1 | ||
|
|
7a2ad47405 | ||
|
|
41551451b0 | ||
|
|
d5c24cd5c8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@
|
|||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
|
/.idea/deviceManager.xml
|
||||||
|
|||||||
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -4,9 +4,8 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="GRADLE" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="jbr-17" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
## Kotatsu contribution guidelines
|
## Kotatsu contribution guidelines
|
||||||
|
|
||||||
- If you want to fix bug or implement a new feature, that already mention in the [issues](https://github.com/KotatsuApp/Kotatsu/issues), please, assign this issue to you and/or comment about it.
|
+ If you want to **fix bugs** or **implement new features** that **already have an [issue card](https://github.com/KotatsuApp/Kotatsu/issues):** please assign this issue to you and/or comment about it.
|
||||||
- Whether you have to implement new feature, please, open an issue or discussion regarding it to ensure it will be accepted.
|
+ If you want to **implement a new feature:** open an issue or discussion regarding it to ensure it will be accepted.
|
||||||
- Translations have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
|
+ **Translations** have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
|
||||||
- In case you want to add a new manga source, refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
|
+ In case you want to **add a new manga source,** refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
|
||||||
|
|
||||||
Refactoring or some dev-faces improvements are also might be accepted, however please stick to the following principles:
|
**Refactoring** or some **dev-faces improvements** might also be accepted. However, please stick to the following principles:
|
||||||
- Performance matters. In the case of choosing between source code beauty and performance, performance should be a priority.
|
|
||||||
- Please, do not modify readme and other information files (except for typos).
|
+ **Performance matters.** In the case of choosing between source code beauty and performance, performance should be a priority.
|
||||||
- Avoid adding new dependencies unless required. APK size is important.
|
+ Please, **do not modify readme and other information files** (except for typos).
|
||||||
|
+ **Avoid adding new dependencies** unless required. APK size is important.
|
||||||
|
|||||||
53
LICENSE
53
LICENSE
@@ -619,56 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
|
|||||||
copy of the Program in return for a fee.
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
|
||||||
notice like this when it starts in an interactive mode:
|
|
||||||
|
|
||||||
<program> Copyright (C) <year> <name of author>
|
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
|
||||||
This is free software, and you are welcome to redistribute it
|
|
||||||
under certain conditions; type `show c' for details.
|
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Kotatsu is a free and open source manga reader for Android.
|
|||||||
* Tablet-optimized Material You UI
|
* Tablet-optimized Material You UI
|
||||||
* Standard and Webtoon-optimized reader
|
* Standard and Webtoon-optimized reader
|
||||||
* Notifications about new chapters with updates feed
|
* Notifications about new chapters with updates feed
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
* Password/fingerprint protect access to the app
|
* Password/fingerprint protect access to the app
|
||||||
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 613
|
versionCode = 642
|
||||||
versionName = '6.6.2'
|
versionName = '7.0.1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -82,29 +82,31 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:789e39b6cb') {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:078b59b1e2') {
|
||||||
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.0.4'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||||
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
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.0'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
implementation 'androidx.fragment:fragment-ktx:1.7.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
|
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-service:2.8.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-process:2.8.0'
|
||||||
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.11.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.0'
|
||||||
|
implementation 'androidx.webkit:webkit:1.11.0'
|
||||||
|
|
||||||
implementation 'androidx.work:work-runtime:2.9.0'
|
implementation 'androidx.work:work-runtime:2.9.0'
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
@@ -119,19 +121,20 @@ 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.7.0'
|
implementation 'com.squareup.okio:okio:3.9.0'
|
||||||
|
|
||||||
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.50'
|
implementation 'com.google.dagger:hilt-android:2.51.1'
|
||||||
kapt 'com.google.dagger:hilt-compiler:2.50'
|
kapt 'com.google.dagger:hilt-compiler:2.51.1'
|
||||||
implementation 'androidx.hilt:hilt-work:1.1.0'
|
implementation 'androidx.hilt:hilt-work:1.2.0'
|
||||||
kapt 'androidx.hilt:hilt-compiler:1.1.0'
|
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
||||||
|
|
||||||
implementation 'io.coil-kt:coil-base:2.5.0'
|
implementation 'io.coil-kt:coil-base:2.6.0'
|
||||||
implementation 'io.coil-kt:coil-svg:2.5.0'
|
implementation 'io.coil-kt:coil-svg:2.6.0'
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
||||||
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'
|
||||||
@@ -141,22 +144,25 @@ dependencies {
|
|||||||
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
|
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
|
||||||
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
|
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
|
||||||
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
|
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
||||||
|
|
||||||
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
|
||||||
|
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.json:json:20231013'
|
testImplementation 'org.json:json:20240303'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||||
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
||||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||||
|
|
||||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||||
|
|
||||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50'
|
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
|
||||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50'
|
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class AppShortcutManagerTest {
|
|||||||
page = 4,
|
page = 4,
|
||||||
scroll = 2,
|
scroll = 2,
|
||||||
percent = 0.3f,
|
percent = 0.3f,
|
||||||
|
force = false,
|
||||||
)
|
)
|
||||||
awaitUpdate()
|
awaitUpdate()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import dagger.hilt.android.testing.HiltAndroidRule
|
|||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -61,6 +63,7 @@ class AppBackupAgentTest {
|
|||||||
page = 3,
|
page = 3,
|
||||||
scroll = 40,
|
scroll = 40,
|
||||||
percent = 0.2f,
|
percent = 0.2f,
|
||||||
|
force = false,
|
||||||
)
|
)
|
||||||
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
||||||
|
|
||||||
|
|||||||
@@ -174,12 +174,12 @@ class TrackerTest {
|
|||||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
||||||
repository.syncWithHistory(mangaFull, chapter.id)
|
tracker.syncWithHistory(mangaFull, chapter.id)
|
||||||
|
|
||||||
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
||||||
repository.syncWithHistory(mangaFull, chapter.id)
|
tracker.syncWithHistory(mangaFull, chapter.id)
|
||||||
|
|
||||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
|
|||||||
12
app/src/debug/AndroidManifest.xml
Normal file
12
app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<manifest
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".tracker.ui.debug.TrackerDebugActivity"
|
||||||
|
android:label="@string/check_for_new_chapters" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.reader.domain.PageLoader
|
|||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
class KotatsuApp : BaseApp() {
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
override fun attachBaseContext(base: Context) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
enableStrictMode()
|
enableStrictMode()
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ class KotatsuApp : BaseApp() {
|
|||||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||||
.penaltyDeath()
|
.penaltyDeath()
|
||||||
.detectFragmentReuse()
|
.detectFragmentReuse()
|
||||||
// .detectWrongFragmentContainer() FIXME: migrate to ViewPager2
|
.detectWrongFragmentContainer()
|
||||||
.detectRetainInstanceUsage()
|
.detectRetainInstanceUsage()
|
||||||
.detectSetUserVisibleHint()
|
.detectSetUserVisibleHint()
|
||||||
.detectFragmentTagUsage()
|
.detectFragmentTagUsage()
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ class CurlLoggingInterceptor(
|
|||||||
private val curlOptions: String? = null
|
private val curlOptions: String? = null
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
|
private val escapeRegex = Regex("([\\[\\]\"])")
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
@@ -40,7 +42,7 @@ class CurlLoggingInterceptor(
|
|||||||
if (isCompressed) {
|
if (isCompressed) {
|
||||||
curlCmd.append(" --compressed")
|
curlCmd.append(" --compressed")
|
||||||
}
|
}
|
||||||
curlCmd.append(" \"").append(request.url).append('"')
|
curlCmd.append(" \"").append(request.url.toString().escape()).append('"')
|
||||||
|
|
||||||
log("---cURL (" + request.url + ")")
|
log("---cURL (" + request.url + ")")
|
||||||
log(curlCmd.toString())
|
log(curlCmd.toString())
|
||||||
@@ -48,7 +50,12 @@ class CurlLoggingInterceptor(
|
|||||||
return chain.proceed(request)
|
return chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.escape() = replace("\"", "\\\"")
|
private fun String.escape() = replace(escapeRegex) { match ->
|
||||||
|
"\\" + match.value
|
||||||
|
}
|
||||||
|
// .replace("\"", "\\\"")
|
||||||
|
// .replace("[", "\\[")
|
||||||
|
// .replace("]", "\\]")
|
||||||
|
|
||||||
private fun log(msg: String) {
|
private fun log(msg: String) {
|
||||||
Log.d("CURL", msg)
|
Log.d("CURL", msg)
|
||||||
|
|||||||
@@ -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,72 @@
|
|||||||
|
package org.koitharu.kotatsu.tracker.ui.debug
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.text.bold
|
||||||
|
import androidx.core.text.buildSpannedString
|
||||||
|
import androidx.core.text.color
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemTrackDebugBinding
|
||||||
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
fun trackDebugAD(
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
coil: ImageLoader,
|
||||||
|
clickListener: OnListItemClickListener<TrackDebugItem>,
|
||||||
|
) = adapterDelegateViewBinding<TrackDebugItem, TrackDebugItem, ItemTrackDebugBinding>(
|
||||||
|
{ layoutInflater, parent -> ItemTrackDebugBinding.inflate(layoutInflater, parent, false) },
|
||||||
|
) {
|
||||||
|
val indicatorNew = ContextCompat.getDrawable(context, R.drawable.ic_new)
|
||||||
|
|
||||||
|
itemView.setOnClickListener { v ->
|
||||||
|
clickListener.onItemClick(item, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind {
|
||||||
|
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||||
|
defaultPlaceholders(context)
|
||||||
|
allowRgb565(true)
|
||||||
|
source(item.manga.source)
|
||||||
|
enqueueWith(coil)
|
||||||
|
}
|
||||||
|
binding.textViewTitle.text = item.manga.title
|
||||||
|
binding.textViewSummary.text = buildSpannedString {
|
||||||
|
item.lastCheckTime?.let {
|
||||||
|
append(
|
||||||
|
DateUtils.getRelativeDateTimeString(
|
||||||
|
context,
|
||||||
|
it.toEpochMilli(),
|
||||||
|
DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
DateUtils.WEEK_IN_MILLIS,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.lastResult == TrackEntity.RESULT_FAILED) {
|
||||||
|
append(" - ")
|
||||||
|
bold {
|
||||||
|
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
|
||||||
|
append(getString(R.string.error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.textViewTitle.drawableStart = if (item.newChapters > 0) {
|
||||||
|
indicatorNew
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.koitharu.kotatsu.tracker.ui.debug
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class TrackDebugItem(
|
||||||
|
val manga: Manga,
|
||||||
|
val lastChapterId: Long,
|
||||||
|
val newChapters: Int,
|
||||||
|
val lastCheckTime: Instant?,
|
||||||
|
val lastChapterDate: Instant?,
|
||||||
|
val lastResult: Int,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is TrackDebugItem && other.manga.id == manga.id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package org.koitharu.kotatsu.tracker.ui.debug
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import coil.ImageLoader
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityTrackerDebugBinding
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class TrackerDebugActivity : BaseActivity<ActivityTrackerDebugBinding>(), OnListItemClickListener<TrackDebugItem> {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
private val viewModel by viewModels<TrackerDebugViewModel>()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(ActivityTrackerDebugBinding.inflate(layoutInflater))
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
val tracksAdapter = BaseListAdapter<TrackDebugItem>()
|
||||||
|
.addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this))
|
||||||
|
with(viewBinding.recyclerView) {
|
||||||
|
adapter = tracksAdapter
|
||||||
|
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||||
|
}
|
||||||
|
viewModel.content.observe(this, tracksAdapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
val rv = viewBinding.recyclerView
|
||||||
|
rv.updatePadding(
|
||||||
|
left = insets.left + rv.paddingTop,
|
||||||
|
right = insets.right + rv.paddingTop,
|
||||||
|
bottom = insets.bottom,
|
||||||
|
)
|
||||||
|
viewBinding.toolbar.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: TrackDebugItem, view: View) {
|
||||||
|
startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.tracker.ui.debug
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toInstantOrNull
|
||||||
|
import org.koitharu.kotatsu.tracker.data.TrackWithManga
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class TrackerDebugViewModel @Inject constructor(
|
||||||
|
private val db: MangaDatabase
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val content = db.getTracksDao().observeAll()
|
||||||
|
.map { it.toUiList() }
|
||||||
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
private fun List<TrackWithManga>.toUiList(): List<TrackDebugItem> = map {
|
||||||
|
TrackDebugItem(
|
||||||
|
manga = it.manga.toManga(emptySet()),
|
||||||
|
lastChapterId = it.track.lastChapterId,
|
||||||
|
newChapters = it.track.newChapters,
|
||||||
|
lastCheckTime = it.track.lastCheckTime.toInstantOrNull(),
|
||||||
|
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
|
||||||
|
lastResult = it.track.lastResult,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/src/debug/res/layout/activity_tracker_debug.xml
Normal file
44
app/src/debug/res/layout/activity_tracker_debug.xml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:id="@+id/appbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fitsSystemWindows="true">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||||
|
android:id="@+id/collapsingToolbarLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/collapsingToolbarLayoutMediumSize"
|
||||||
|
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
|
||||||
|
app:toolbarId="@id/toolbar">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
app:layout_collapseMode="pin" />
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="@dimen/list_spacing_normal"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
|
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
|
||||||
|
tools:listitem="@layout/item_track_debug" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="72dp"
|
||||||
android:background="@drawable/list_selector"
|
android:background="@drawable/list_selector"
|
||||||
android:clipChildren="false">
|
android:clipChildren="false">
|
||||||
|
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
||||||
@@ -26,42 +28,29 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
|
android:drawablePadding="8dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/textView_subtitle"
|
app:layout_constraintBottom_toTopOf="@+id/textView_summary"
|
||||||
app:layout_constraintEnd_toStartOf="@id/button_more"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
|
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
|
||||||
tools:text="@tools:sample/lorem" />
|
tools:text="@tools:sample/lorem" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/textView_subtitle"
|
android:id="@+id/textView_summary"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
|
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
|
||||||
app:layout_constraintEnd_toStartOf="@id/button_more"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
||||||
tools:text="@tools:sample/lorem/random" />
|
tools:text="@tools:sample/lorem/random" />
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/button_more"
|
|
||||||
style="@style/Widget.Kotatsu.ExploreButton"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_margin="8dp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:minWidth="120dp"
|
|
||||||
android:text="@string/more"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
@@ -8,4 +8,14 @@
|
|||||||
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_tracker"
|
||||||
|
android:title="@string/check_for_new_chapters"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@id/action_works"
|
||||||
|
android:title="Works"
|
||||||
|
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>
|
||||||
|
|||||||
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-----
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||||
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val MAX_PARALLELISM = 4
|
||||||
|
private const val MATCH_THRESHOLD = 0.2f
|
||||||
|
|
||||||
|
class AlternativesUseCase @Inject constructor(
|
||||||
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||||
|
val sources = getSources(manga.source)
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
return emptyFlow()
|
||||||
|
}
|
||||||
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||||
|
return channelFlow {
|
||||||
|
for (source in sources) {
|
||||||
|
val repository = mangaRepositoryFactory.create(source)
|
||||||
|
if (!repository.isSearchSupported) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
val list = runCatchingCancellable {
|
||||||
|
semaphore.withPermit {
|
||||||
|
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
for (item in list) {
|
||||||
|
if (item.matches(manga)) {
|
||||||
|
send(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.map {
|
||||||
|
runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||||
|
}.getOrDefault(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
||||||
|
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
|
||||||
|
result.addAll(sourcesRepository.getEnabledSources())
|
||||||
|
result.sortByDescending { it.priority(ref) }
|
||||||
|
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Manga.matches(ref: Manga): Boolean {
|
||||||
|
return matchesTitles(title, ref.title) ||
|
||||||
|
matchesTitles(title, ref.altTitle) ||
|
||||||
|
matchesTitles(altTitle, ref.title) ||
|
||||||
|
matchesTitles(altTitle, ref.altTitle)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesTitles(a: String?, b: String?): Boolean {
|
||||||
|
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||||
|
var res = 0
|
||||||
|
if (locale == ref.locale) res += 2
|
||||||
|
if (contentType == ref.contentType) res++
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.domain
|
||||||
|
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||||
|
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.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MigrateUseCase @Inject constructor(
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
|
private val database: MangaDatabase,
|
||||||
|
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
||||||
|
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
|
runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
|
}.getOrDefault(oldManga)
|
||||||
|
} else {
|
||||||
|
oldManga
|
||||||
|
}
|
||||||
|
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||||
|
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||||
|
} else {
|
||||||
|
newManga
|
||||||
|
}
|
||||||
|
mangaDataRepository.storeManga(newDetails)
|
||||||
|
database.withTransaction {
|
||||||
|
// replace favorites
|
||||||
|
val favoritesDao = database.getFavouritesDao()
|
||||||
|
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||||
|
if (oldFavourites.isNotEmpty()) {
|
||||||
|
favoritesDao.delete(oldManga.id)
|
||||||
|
for (f in oldFavourites) {
|
||||||
|
val e = f.copy(
|
||||||
|
mangaId = newManga.id,
|
||||||
|
)
|
||||||
|
favoritesDao.upsert(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// replace history
|
||||||
|
val historyDao = database.getHistoryDao()
|
||||||
|
val oldHistory = historyDao.find(oldDetails.id)
|
||||||
|
if (oldHistory != null) {
|
||||||
|
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||||
|
historyDao.delete(oldDetails.id)
|
||||||
|
historyDao.upsert(newHistory)
|
||||||
|
}
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
tracksDao.delete(oldDetails.id)
|
||||||
|
tracksDao.upsert(newTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progressUpdateUseCase(newManga)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeNewHistory(
|
||||||
|
oldManga: Manga,
|
||||||
|
newManga: Manga,
|
||||||
|
history: HistoryEntity,
|
||||||
|
): HistoryEntity {
|
||||||
|
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||||
|
val branch = newManga.getPreferredBranch(null)
|
||||||
|
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||||
|
val currentChapter = if (history.percent in 0f..1f) {
|
||||||
|
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||||
|
} else {
|
||||||
|
chapters.first()
|
||||||
|
}
|
||||||
|
return HistoryEntity(
|
||||||
|
mangaId = newManga.id,
|
||||||
|
createdAt = history.createdAt,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
chapterId = currentChapter.id,
|
||||||
|
page = history.page,
|
||||||
|
scroll = history.scroll,
|
||||||
|
percent = history.percent,
|
||||||
|
deletedAt = 0,
|
||||||
|
chaptersCount = chapters.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||||
|
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||||
|
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||||
|
if (index < 0) {
|
||||||
|
index = if (history.percent in 0f..1f) {
|
||||||
|
(oldChapters.lastIndex * history.percent).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||||
|
val newBranch = if (newChapters.containsKey(branch)) {
|
||||||
|
branch
|
||||||
|
} else {
|
||||||
|
newManga.getPreferredBranch(null)
|
||||||
|
}
|
||||||
|
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
||||||
|
val oldChapter = oldChapters[index]
|
||||||
|
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||||
|
}.id
|
||||||
|
|
||||||
|
return HistoryEntity(
|
||||||
|
mangaId = newManga.id,
|
||||||
|
createdAt = history.createdAt,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
chapterId = newChapterId,
|
||||||
|
page = history.page,
|
||||||
|
scroll = history.scroll,
|
||||||
|
percent = PROGRESS_NONE,
|
||||||
|
deletedAt = 0,
|
||||||
|
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
||||||
|
return if (number <= 0f) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
firstOrNull { it.volume == volume && it.number == number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.text.style.ForegroundColorSpan
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.text.buildSpannedString
|
||||||
|
import androidx.core.text.inSpans
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.transform.CircleCropTransformation
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
|
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.newImageRequest
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import kotlin.math.sign
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
fun alternativeAD(
|
||||||
|
coil: ImageLoader,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
listener: OnListItemClickListener<MangaAlternativeModel>,
|
||||||
|
) = adapterDelegateViewBinding<MangaAlternativeModel, ListModel, ItemMangaAlternativeBinding>(
|
||||||
|
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
|
||||||
|
) {
|
||||||
|
|
||||||
|
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
|
||||||
|
val colorRed = ContextCompat.getColor(context, R.color.common_red)
|
||||||
|
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||||
|
itemView.setOnClickListener(clickListener)
|
||||||
|
binding.buttonMigrate.setOnClickListener(clickListener)
|
||||||
|
binding.chipSource.setOnClickListener(clickListener)
|
||||||
|
|
||||||
|
bind { payloads ->
|
||||||
|
binding.textViewTitle.text = item.manga.title
|
||||||
|
binding.textViewSubtitle.text = buildSpannedString {
|
||||||
|
if (item.chaptersCount > 0) {
|
||||||
|
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
|
||||||
|
} else {
|
||||||
|
append(context.getString(R.string.no_chapters))
|
||||||
|
}
|
||||||
|
when (item.chaptersDiff.sign) {
|
||||||
|
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
|
||||||
|
append(" ▼ ")
|
||||||
|
append(item.chaptersDiff.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
|
||||||
|
append(" ▲ +")
|
||||||
|
append(item.chaptersDiff.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||||
|
binding.chipSource.also { chip ->
|
||||||
|
chip.text = item.manga.source.title
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(item.manga.source.faviconUri())
|
||||||
|
.lifecycle(lifecycleOwner)
|
||||||
|
.crossfade(false)
|
||||||
|
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||||
|
.target(ChipIconTarget(chip))
|
||||||
|
.placeholder(R.drawable.ic_web)
|
||||||
|
.fallback(R.drawable.ic_web)
|
||||||
|
.error(R.drawable.ic_web)
|
||||||
|
.source(item.manga.source)
|
||||||
|
.transformations(CircleCropTransformation())
|
||||||
|
.allowRgb565(true)
|
||||||
|
.enqueueWith(coil)
|
||||||
|
}
|
||||||
|
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||||
|
size(CoverSizeResolver(binding.imageViewCover))
|
||||||
|
defaultPlaceholders(context)
|
||||||
|
transformations(TrimTransformation())
|
||||||
|
allowRgb565(true)
|
||||||
|
tag(item.manga)
|
||||||
|
source(item.manga.source)
|
||||||
|
enqueueWith(coil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import coil.ImageLoader
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
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.observeEvent
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||||
|
OnListItemClickListener<MangaAlternativeModel> {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
private val viewModel by viewModels<AlternativesViewModel>()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
|
||||||
|
supportActionBar?.run {
|
||||||
|
setDisplayHomeAsUpEnabled(true)
|
||||||
|
subtitle = viewModel.manga.title
|
||||||
|
}
|
||||||
|
val listAdapter = BaseListAdapter<ListModel>()
|
||||||
|
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
||||||
|
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
||||||
|
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
|
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
|
with(viewBinding.recyclerView) {
|
||||||
|
setHasFixedSize(true)
|
||||||
|
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||||
|
adapter = listAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||||
|
viewModel.content.observe(this, listAdapter)
|
||||||
|
viewModel.onMigrated.observeEvent(this) {
|
||||||
|
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||||
|
startActivity(DetailsActivity.newIntent(this, it))
|
||||||
|
finishAfterTransition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
viewBinding.root.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
)
|
||||||
|
viewBinding.recyclerView.updatePadding(
|
||||||
|
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||||
|
when (view.id) {
|
||||||
|
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
||||||
|
R.id.button_migrate -> confirmMigration(item.manga)
|
||||||
|
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmMigration(target: Manga) {
|
||||||
|
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
||||||
|
.setIcon(R.drawable.ic_replace)
|
||||||
|
.setTitle(R.string.manga_migration)
|
||||||
|
.setMessage(
|
||||||
|
getString(
|
||||||
|
R.string.migrate_confirmation,
|
||||||
|
viewModel.manga.title,
|
||||||
|
viewModel.manga.source.title,
|
||||||
|
target.title,
|
||||||
|
target.source.title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.migrate) { _, _ ->
|
||||||
|
viewModel.migrate(target)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
|
||||||
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEmpty
|
||||||
|
import kotlinx.coroutines.flow.runningFold
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
||||||
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AlternativesViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
|
private val migrateUseCase: MigrateUseCase,
|
||||||
|
private val extraProvider: ListExtraProvider,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||||
|
|
||||||
|
val onMigrated = MutableEventFlow<Manga>()
|
||||||
|
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
||||||
|
private var migrationJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
val ref = runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||||
|
}.getOrDefault(manga)
|
||||||
|
val refCount = ref.chaptersCount()
|
||||||
|
alternativesUseCase(ref)
|
||||||
|
.map {
|
||||||
|
MangaAlternativeModel(
|
||||||
|
manga = it,
|
||||||
|
progress = extraProvider.getProgress(it.id),
|
||||||
|
referenceChapters = refCount,
|
||||||
|
)
|
||||||
|
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||||
|
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
|
||||||
|
}.onEmpty {
|
||||||
|
emit(
|
||||||
|
listOf(
|
||||||
|
EmptyState(
|
||||||
|
icon = R.drawable.ic_empty_common,
|
||||||
|
textPrimary = R.string.nothing_found,
|
||||||
|
textSecondary = R.string.text_search_holder_secondary,
|
||||||
|
actionStringRes = 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.collect {
|
||||||
|
content.value = it
|
||||||
|
}
|
||||||
|
content.value = content.value.filterNot { it is LoadingFooter }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun migrate(target: Manga) {
|
||||||
|
if (migrationJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
migrationJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
migrateUseCase(manga, target)
|
||||||
|
onMigrated.call(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
||||||
|
return list.map {
|
||||||
|
MangaAlternativeModel(
|
||||||
|
manga = it,
|
||||||
|
progress = extraProvider.getProgress(it.id),
|
||||||
|
referenceChapters = refCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
data class MangaAlternativeModel(
|
||||||
|
val manga: Manga,
|
||||||
|
val progress: Float,
|
||||||
|
private val referenceChapters: Int,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
val chaptersCount = manga.chaptersCount()
|
||||||
|
|
||||||
|
val chaptersDiff: Int
|
||||||
|
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is MangaAlternativeModel && other.manga.id == manga.id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,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 +25,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,7 +42,7 @@ 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>,
|
||||||
@@ -54,7 +55,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 +72,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 +86,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 +101,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))
|
||||||
}
|
}
|
||||||
@@ -206,11 +207,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
|
||||||
@@ -30,9 +30,7 @@ fun bookmarkLargeAD(
|
|||||||
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)
|
||||||
@@ -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
|
||||||
@@ -29,9 +29,7 @@ fun bookmarkListAD(
|
|||||||
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,44 +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.ListHeader
|
|
||||||
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? {
|
|
||||||
val list = items
|
|
||||||
for (i in (0..position).reversed()) {
|
|
||||||
val item = list.getOrNull(i) ?: continue
|
|
||||||
if (item is ListHeader) {
|
|
||||||
return item.getText(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,31 +11,42 @@ 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.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
|
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.network.UserAgents
|
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 (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
|
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
supportActionBar?.run {
|
supportActionBar?.run {
|
||||||
setDisplayHomeAsUpEnabled(true)
|
setDisplayHomeAsUpEnabled(true)
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
with(viewBinding.webView.settings) {
|
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
|
||||||
javaScriptEnabled = true
|
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
||||||
userAgentString = UserAgents.CHROME_MOBILE
|
repository?.headers?.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)
|
||||||
@@ -57,16 +67,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)
|
||||||
@@ -81,11 +81,14 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_browser -> {
|
R.id.action_browser -> {
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
val url = viewBinding.webView.url?.toUriOrNull()
|
||||||
intent.data = Uri.parse(viewBinding.webView.url)
|
if (url != null) {
|
||||||
try {
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
startActivity(Intent.createChooser(intent, item.title))
|
intent.data = url
|
||||||
} catch (_: ActivityNotFoundException) {
|
try {
|
||||||
|
startActivity(Intent.createChooser(intent, item.title))
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -136,11 +139,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,7 +23,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,13 +36,14 @@ 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?.contentType == ContentType.HENTAI) {
|
||||||
@@ -55,8 +59,21 @@ class CaptchaNotifier(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.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 +99,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,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.TaggedActivityResult
|
||||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
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.network.UserAgents
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -41,17 +43,12 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var cookieJar: MutableCookieJar
|
lateinit var cookieJar: MutableCookieJar
|
||||||
|
|
||||||
|
private lateinit var cfClient: CloudFlareClient
|
||||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (!catchingWebViewUnavailability {
|
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||||
setContentView(
|
|
||||||
ActivityBrowserBinding.inflate(
|
|
||||||
layoutInflater,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
supportActionBar?.run {
|
supportActionBar?.run {
|
||||||
@@ -59,13 +56,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
val url = intent?.dataString.orEmpty()
|
val url = intent?.dataString.orEmpty()
|
||||||
with(viewBinding.webView.settings) {
|
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||||
javaScriptEnabled = true
|
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
||||||
domStorageEnabled = true
|
viewBinding.webView.webViewClient = cfClient
|
||||||
databaseEnabled = true
|
|
||||||
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
|
|
||||||
}
|
|
||||||
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||||
onBackPressedDispatcher.addCallback(it)
|
onBackPressedDispatcher.addCallback(it)
|
||||||
}
|
}
|
||||||
@@ -91,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)
|
||||||
@@ -125,15 +108,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_retry -> {
|
R.id.action_retry -> {
|
||||||
lifecycleScope.launch {
|
restartCheck()
|
||||||
viewBinding.webView.stopLoading()
|
|
||||||
yield()
|
|
||||||
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
|
||||||
if (targetUrl != null) {
|
|
||||||
clearCfCookies(targetUrl)
|
|
||||||
viewBinding.webView.loadUrl(targetUrl.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,8 +134,16 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
viewBinding.progressBar.isInvisible = true
|
viewBinding.progressBar.isInvisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onLoopDetected() {
|
||||||
|
restartCheck()
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,16 +161,29 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
private fun restartCheck() {
|
||||||
cookieJar.removeCookies(url) { cookie ->
|
lifecycleScope.launch {
|
||||||
val name = cookie.name
|
viewBinding.webView.stopLoading()
|
||||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf")
|
yield()
|
||||||
|
cfClient.reset()
|
||||||
|
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
||||||
|
if (targetUrl != null) {
|
||||||
|
clearCfCookies(targetUrl)
|
||||||
|
viewBinding.webView.loadUrl(targetUrl.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
|
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
||||||
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
|
cookieJar.removeCookies(url) { cookie ->
|
||||||
return newIntent(context, input.first, input.second)
|
val name = cookie.name
|
||||||
|
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Contract : ActivityResultContract<CloudFlareProtectedException, TaggedActivityResult>() {
|
||||||
|
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
||||||
|
return newIntent(context, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||||
@@ -199,13 +195,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ interface CloudFlareCallback : BrowserCallback {
|
|||||||
fun onPageLoaded()
|
fun onPageLoaded()
|
||||||
|
|
||||||
fun onCheckPassed()
|
fun onCheckPassed()
|
||||||
|
|
||||||
|
fun onLoopDetected()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.browser.BrowserClient
|
|||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
|
|
||||||
private const val CF_CLEARANCE = "cf_clearance"
|
private const val CF_CLEARANCE = "cf_clearance"
|
||||||
|
private const val LOOP_COUNTER = 3
|
||||||
|
|
||||||
class CloudFlareClient(
|
class CloudFlareClient(
|
||||||
private val cookieJar: MutableCookieJar,
|
private val cookieJar: MutableCookieJar,
|
||||||
@@ -15,6 +16,7 @@ class CloudFlareClient(
|
|||||||
) : BrowserClient(callback) {
|
) : BrowserClient(callback) {
|
||||||
|
|
||||||
private val oldClearance = getClearance()
|
private val oldClearance = getClearance()
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
super.onPageStarted(view, url, favicon)
|
super.onPageStarted(view, url, favicon)
|
||||||
@@ -31,10 +33,20 @@ class CloudFlareClient(
|
|||||||
callback.onPageLoaded()
|
callback.onPageLoaded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
counter = 0
|
||||||
|
}
|
||||||
|
|
||||||
private fun checkClearance() {
|
private fun checkClearance() {
|
||||||
val clearance = getClearance()
|
val clearance = getClearance()
|
||||||
if (clearance != null && clearance != oldClearance) {
|
if (clearance != null && clearance != oldClearance) {
|
||||||
callback.onCheckPassed()
|
callback.onCheckPassed()
|
||||||
|
} else {
|
||||||
|
counter++
|
||||||
|
if (counter >= LOOP_COUNTER) {
|
||||||
|
reset()
|
||||||
|
callback.onLoopDetected()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ 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.ImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
@@ -37,12 +34,14 @@ 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
|
||||||
@@ -50,11 +49,11 @@ 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.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 +71,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 +87,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,11 +99,14 @@ 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)
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
@@ -113,7 +116,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 +151,13 @@ interface AppModule {
|
|||||||
fun provideActivityLifecycleCallbacks(
|
fun provideActivityLifecycleCallbacks(
|
||||||
appProtectHelper: AppProtectHelper,
|
appProtectHelper: AppProtectHelper,
|
||||||
activityRecreationHandle: ActivityRecreationHandle,
|
activityRecreationHandle: ActivityRecreationHandle,
|
||||||
incognitoModeIndicator: IncognitoModeIndicator,
|
|
||||||
acraScreenLogger: AcraScreenLogger,
|
acraScreenLogger: AcraScreenLogger,
|
||||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||||
appProtectHelper,
|
appProtectHelper,
|
||||||
activityRecreationHandle,
|
activityRecreationHandle,
|
||||||
incognitoModeIndicator,
|
|
||||||
acraScreenLogger,
|
acraScreenLogger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideContentCache(
|
|
||||||
application: Application,
|
|
||||||
): ContentCache {
|
|
||||||
return if (application.isLowRamDevice()) {
|
|
||||||
StubContentCache()
|
|
||||||
} else {
|
|
||||||
MemoryContentCache(application)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@LocalStorageChanges
|
@LocalStorageChanges
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
@@ -19,6 +20,7 @@ import org.acra.config.httpSender
|
|||||||
import org.acra.data.StringFormat
|
import org.acra.data.StringFormat
|
||||||
import org.acra.ktx.initAcra
|
import org.acra.ktx.initAcra
|
||||||
import org.acra.sender.HttpSender
|
import org.acra.sender.HttpSender
|
||||||
|
import org.conscrypt.Conscrypt
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
@@ -27,6 +29,7 @@ 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.settings.work.WorkScheduleManager
|
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||||
|
import java.security.Security
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
|
|
||||||
@@ -34,7 +37,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>
|
||||||
@@ -52,7 +55,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
lateinit var appValidator: AppValidator
|
lateinit var appValidator: AppValidator
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var workScheduleManager: Provider<WorkScheduleManager>
|
lateinit var workScheduleManager: WorkScheduleManager
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
lateinit var workManagerProvider: Provider<WorkManager>
|
||||||
@@ -66,6 +69,10 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||||
|
// TLS 1.3 support for Android < 10
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||||
|
}
|
||||||
setupActivityLifecycleCallbacks()
|
setupActivityLifecycleCallbacks()
|
||||||
processLifecycleScope.launch {
|
processLifecycleScope.launch {
|
||||||
val isOriginalApp = withContext(Dispatchers.Default) {
|
val isOriginalApp = withContext(Dispatchers.Default) {
|
||||||
@@ -76,11 +83,11 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
}
|
}
|
||||||
workScheduleManager.get().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
|
||||||
@@ -116,7 +123,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
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)
|
||||||
|
|
||||||
@@ -41,4 +43,17 @@ class BackupZipInput(val file: File) : Closeable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun from(file: File): BackupZipInput = try {
|
||||||
|
val res = BackupZipInput(file)
|
||||||
|
if (res.zipFile.getEntry("index") == null) {
|
||||||
|
throw BadBackupFormatException(null)
|
||||||
|
}
|
||||||
|
res
|
||||||
|
} catch (e: ZipException) {
|
||||||
|
throw BadBackupFormatException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class JsonDeserializer(private val json: JSONObject) {
|
|||||||
page = json.getInt("page"),
|
page = json.getInt("page"),
|
||||||
scroll = json.getDouble("scroll").toFloat(),
|
scroll = json.getDouble("scroll").toFloat(),
|
||||||
percent = json.getFloatOrDefault("percent", -1f),
|
percent = json.getFloatOrDefault("percent", -1f),
|
||||||
|
chaptersCount = json.getIntOrDefault("chapters", -1),
|
||||||
deletedAt = 0L,
|
deletedAt = 0L,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
|||||||
put("page", e.page)
|
put("page", e.page)
|
||||||
put("scroll", e.scroll)
|
put("scroll", e.scroll)
|
||||||
put("percent", e.percent)
|
put("percent", e.percent)
|
||||||
|
put("chapters", e.chaptersCount)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +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>>)
|
|
||||||
|
|
||||||
data class Key(
|
|
||||||
val source: MangaSource,
|
|
||||||
val url: String,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,16 +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<CacheKey> {
|
||||||
|
|
||||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
|
||||||
|
|
||||||
operator fun get(key: ContentCache.Key): T? {
|
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -19,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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,4 +33,8 @@ class ExpiringLruCache<T>(
|
|||||||
fun trimToSize(size: Int) {
|
fun trimToSize(size: Int) {
|
||||||
cache.trimToSize(size)
|
cache.trimToSize(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun remove(key: CacheKey) {
|
||||||
|
cache.remove(key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,45 +3,57 @@ 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()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
application.registerComponentCallbacks(this)
|
application.registerComponentCallbacks(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
|
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
||||||
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
|
private val pagesCache =
|
||||||
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
|
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)
|
||||||
|
|
||||||
override val isCachingEnabled: Boolean = true
|
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||||
|
return detailsCache[Key(source, url)]?.awaitOrNull()
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(source: MangaSource) {
|
||||||
|
clearCache(detailsCache, source)
|
||||||
|
clearCache(pagesCache, source)
|
||||||
|
clearCache(relatedMangaCache, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||||
@@ -67,4 +79,17 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
|
|||||||
else -> cache.trimToSize(cache.maxSize / 2)
|
else -> cache.trimToSize(cache.maxSize / 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
||||||
|
cache.forEach { key ->
|
||||||
|
if (key.source == source) {
|
||||||
|
cache.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Key(
|
||||||
|
val source: MangaSource,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +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
|
|
||||||
}
|
|
||||||
@@ -30,6 +30,8 @@ import org.koitharu.kotatsu.core.db.migrations.Migration14To15
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
|
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.Migration19To20
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||||
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
|
||||||
@@ -48,20 +50,22 @@ 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.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.StatsEntity
|
||||||
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
|
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
|
||||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
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 = 18
|
const val DATABASE_VERSION = 20
|
||||||
|
|
||||||
@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,
|
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION,
|
||||||
)
|
)
|
||||||
@@ -90,6 +94,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
abstract fun getScrobblingDao(): ScrobblingDao
|
abstract fun getScrobblingDao(): ScrobblingDao
|
||||||
|
|
||||||
abstract fun getSourcesDao(): MangaSourcesDao
|
abstract fun getSourcesDao(): MangaSourcesDao
|
||||||
|
|
||||||
|
abstract fun getStatsDao(): StatsDao
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||||
@@ -110,6 +116,8 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration15To16(),
|
Migration15To16(),
|
||||||
Migration16To17(context),
|
Migration16To17(context),
|
||||||
Migration17To18(),
|
Migration17To18(),
|
||||||
|
Migration18To19(),
|
||||||
|
Migration19To20(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
@Query("SELECT * FROM sources ORDER BY 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")
|
|
||||||
abstract fun observeDisabled(): Flow<List<MangaSourceEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT enabled FROM sources WHERE source = :source")
|
||||||
|
abstract fun observeIsEnabled(source: String): Flow<Boolean>
|
||||||
|
|
||||||
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
|
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
|
||||||
abstract suspend fun getMaxSortKey(): Int
|
abstract suspend fun getMaxSortKey(): Int
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
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.Transaction
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
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
|
||||||
@@ -12,18 +16,24 @@ interface TrackLogsDao {
|
|||||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
||||||
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
|
||||||
|
fun observeUnreadCount(): Flow<Int>
|
||||||
|
|
||||||
@Query("DELETE FROM track_logs")
|
@Query("DELETE FROM track_logs")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|
||||||
|
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
|
||||||
|
suspend fun markAsRead(id: Long)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insert(entity: TrackLogEntity): Long
|
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()
|
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)")
|
||||||
|
suspend fun trim(size: Int)
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM track_logs")
|
@Query("SELECT COUNT(*) FROM track_logs")
|
||||||
suspend fun count(): Int
|
suspend fun count(): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration18To19 : Migration(18, 19) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
|
||||||
|
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,5 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class BadBackupFormatException(cause: Throwable?) : IOException(cause)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okhttp3.Headers
|
||||||
|
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,7 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
|
||||||
|
class NoDataReceivedException(
|
||||||
|
private val url: String,
|
||||||
|
) : IOException("No data has been received from $url")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
class UnsupportedSourceException(
|
||||||
|
message: String?,
|
||||||
|
val manga: Manga?,
|
||||||
|
) : IllegalArgumentException(message)
|
||||||
@@ -21,7 +21,7 @@ abstract class ErrorObserver(
|
|||||||
private val onResolved: Consumer<Boolean>?,
|
private val onResolved: Consumer<Boolean>?,
|
||||||
) : FlowCollector<Throwable> {
|
) : FlowCollector<Throwable> {
|
||||||
|
|
||||||
protected val activity = host.context.findActivity()
|
protected open val activity = host.context.findActivity()
|
||||||
|
|
||||||
private val lifecycleScope: LifecycleCoroutineScope
|
private val lifecycleScope: LifecycleCoroutineScope
|
||||||
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
|
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
|
||||||
@@ -36,7 +36,7 @@ abstract class ErrorObserver(
|
|||||||
private fun isAlive(): Boolean {
|
private fun isAlive(): Boolean {
|
||||||
return when {
|
return when {
|
||||||
fragment != null -> fragment.view != null
|
fragment != null -> fragment.view != null
|
||||||
activity != null -> !activity.isDestroyed
|
activity != null -> activity?.isDestroyed == false
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.collection.ArrayMap
|
import androidx.collection.ArrayMap
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import okhttp3.Headers
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
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.UnsupportedSourceException
|
||||||
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.util.TaggedActivityResult
|
||||||
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.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
@@ -27,7 +29,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
private val activity: FragmentActivity?
|
private val activity: FragmentActivity?
|
||||||
private val fragment: Fragment?
|
private val fragment: Fragment?
|
||||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||||
private val cloudflareContract: ActivityResultLauncher<Pair<String, Headers?>>
|
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
||||||
|
|
||||||
constructor(activity: FragmentActivity) {
|
constructor(activity: FragmentActivity) {
|
||||||
this.activity = activity
|
this.activity = activity
|
||||||
@@ -52,19 +54,24 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 NotFoundException -> {
|
is NotFoundException -> {
|
||||||
openInBrowser(e.url)
|
openInBrowser(e.url)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is UnsupportedSourceException -> {
|
||||||
|
e.manga?.let { openAlternatives(it) }
|
||||||
|
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 ->
|
||||||
@@ -74,7 +81,12 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
|
|
||||||
private fun openInBrowser(url: String) {
|
private fun openInBrowser(url: String) {
|
||||||
val context = activity ?: fragment?.activity ?: return
|
val context = activity ?: fragment?.activity ?: return
|
||||||
context.startActivity(BrowserActivity.newIntent(context, url, null))
|
context.startActivity(BrowserActivity.newIntent(context, url, null, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAlternatives(manga: Manga) {
|
||||||
|
val context = activity ?: fragment?.activity ?: return
|
||||||
|
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||||
@@ -86,6 +98,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||||
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
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.model
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.collection.MutableObjectIntMap
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
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
|
||||||
@@ -12,6 +13,7 @@ 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.MangaSource
|
||||||
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 com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -29,12 +31,14 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
|||||||
if (size <= 1) {
|
if (size <= 1) {
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
val acc = HashMap<String?, Int>()
|
val acc = MutableObjectIntMap<String?>()
|
||||||
for (item in this) {
|
for (item in this) {
|
||||||
val branch = item.chapter.branch
|
val branch = item.chapter.branch
|
||||||
acc[branch] = (acc[branch] ?: 0) + 1
|
acc[branch] = acc.getOrDefault(branch, 0) + 1
|
||||||
}
|
}
|
||||||
return acc.values.max()
|
var max = 0
|
||||||
|
acc.forEachValue { x -> if (x > max) max = x }
|
||||||
|
return max
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
@@ -113,3 +117,25 @@ val Manga.appUrl: Uri
|
|||||||
.appendQueryParameter("name", title)
|
.appendQueryParameter("name", title)
|
||||||
.appendQueryParameter("url", url)
|
.appendQueryParameter("url", url)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
|
||||||
|
number.formatSimple()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Manga.chaptersCount(): Int {
|
||||||
|
if (chapters.isNullOrEmpty()) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val counters = MutableObjectIntMap<String?>()
|
||||||
|
var max = 0
|
||||||
|
chapters?.forEach { x ->
|
||||||
|
val c = counters.getOrDefault(x.branch, 0) + 1
|
||||||
|
counters[x.branch] = c
|
||||||
|
if (max < c) {
|
||||||
|
max = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ data class ParcelableChapter(
|
|||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = parcel.readLong(),
|
id = parcel.readLong(),
|
||||||
name = parcel.readString().orEmpty(),
|
name = parcel.readString().orEmpty(),
|
||||||
number = parcel.readInt(),
|
number = parcel.readFloat(),
|
||||||
|
volume = parcel.readInt(),
|
||||||
url = parcel.readString().orEmpty(),
|
url = parcel.readString().orEmpty(),
|
||||||
scanlator = parcel.readString(),
|
scanlator = parcel.readString(),
|
||||||
uploadDate = parcel.readLong(),
|
uploadDate = parcel.readLong(),
|
||||||
@@ -31,7 +32,8 @@ data class ParcelableChapter(
|
|||||||
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
||||||
parcel.writeLong(id)
|
parcel.writeLong(id)
|
||||||
parcel.writeString(name)
|
parcel.writeString(name)
|
||||||
parcel.writeInt(number)
|
parcel.writeFloat(number)
|
||||||
|
parcel.writeInt(volume)
|
||||||
parcel.writeString(url)
|
parcel.writeString(url)
|
||||||
parcel.writeString(scanlator)
|
parcel.writeString(scanlator)
|
||||||
parcel.writeLong(uploadDate)
|
parcel.writeLong(uploadDate)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import okhttp3.Interceptor
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import org.jsoup.Jsoup
|
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 java.net.HttpURLConnection.HTTP_FORBIDDEN
|
||||||
@@ -17,14 +18,23 @@ class CloudFlareInterceptor : Interceptor {
|
|||||||
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
|
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
|
||||||
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
|
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
|
||||||
} ?: return response
|
} ?: return response
|
||||||
if (content.getElementById("challenge-error-title") != null) {
|
val hasCaptcha = content.getElementById("challenge-error-title") != null
|
||||||
|
val isBlocked = content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null
|
||||||
|
if (hasCaptcha || isBlocked) {
|
||||||
val request = response.request
|
val request = response.request
|
||||||
response.closeQuietly()
|
response.closeQuietly()
|
||||||
throw CloudFlareProtectedException(
|
if (isBlocked) {
|
||||||
url = request.url.toString(),
|
throw CloudFlareBlockedException(
|
||||||
source = request.tag(MangaSource::class.java),
|
url = request.url.toString(),
|
||||||
headers = request.headers,
|
source = request.tag(MangaSource::class.java),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
throw CloudFlareProtectedException(
|
||||||
|
url = request.url.toString(),
|
||||||
|
source = request.tag(MangaSource::class.java),
|
||||||
|
headers = request.headers,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import okhttp3.Interceptor
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
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.RemoteMangaRepository
|
||||||
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.network.UserAgents
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||||
import java.net.IDN
|
import java.net.IDN
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -20,6 +20,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class CommonHeadersInterceptor @Inject constructor(
|
class CommonHeadersInterceptor @Inject constructor(
|
||||||
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
|
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
|
||||||
|
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
@@ -38,7 +39,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||||
}
|
}
|
||||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||||
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
|
headersBuilder[CommonHeaders.USER_AGENT] = mangaLoaderContextLazy.get().getDefaultUserAgent()
|
||||||
}
|
}
|
||||||
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
|
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
|
||||||
val idn = IDN.toASCII(repository.domain)
|
val idn = IDN.toASCII(repository.domain)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import coil.request.ErrorResult
|
|||||||
import coil.request.ImageResult
|
import coil.request.ImageResult
|
||||||
import coil.request.SuccessResult
|
import coil.request.SuccessResult
|
||||||
import coil.size.Dimension
|
import coil.size.Dimension
|
||||||
|
import coil.size.isOriginal
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -46,11 +47,13 @@ class ImageProxyInterceptor @Inject constructor(
|
|||||||
.scheme("https")
|
.scheme("https")
|
||||||
.host("wsrv.nl")
|
.host("wsrv.nl")
|
||||||
.addQueryParameter("url", url.toString())
|
.addQueryParameter("url", url.toString())
|
||||||
.addQueryParameter("fit", "outside")
|
|
||||||
.addQueryParameter("we", null)
|
.addQueryParameter("we", null)
|
||||||
val size = request.sizeResolver.size()
|
val size = request.sizeResolver.size()
|
||||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
if (!size.isOriginal) {
|
||||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
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()
|
val newRequest = request.newBuilder()
|
||||||
.data(newUrl.build())
|
.data(newUrl.build())
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ 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.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||||
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
|
||||||
@@ -50,10 +52,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 +66,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 {
|
||||||
|
installExtraCertsificates(contextProvider.get())
|
||||||
}
|
}
|
||||||
cache(cache)
|
cache(cache)
|
||||||
addInterceptor(GZipInterceptor())
|
addInterceptor(GZipInterceptor())
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocketFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
@SuppressLint("CustomX509TrustManager")
|
|
||||||
fun OkHttpClient.Builder.bypassSSLErrors() = also { builder ->
|
|
||||||
runCatching {
|
|
||||||
val trustAllCerts = object : X509TrustManager {
|
|
||||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = Unit
|
|
||||||
|
|
||||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) = Unit
|
|
||||||
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
|
||||||
}
|
|
||||||
val sslContext = SSLContext.getInstance("SSL")
|
|
||||||
sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())
|
|
||||||
val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory
|
|
||||||
builder.sslSocketFactory(sslSocketFactory, trustAllCerts)
|
|
||||||
builder.hostnameVerifier { _, _ -> true }
|
|
||||||
}.onFailure {
|
|
||||||
it.printStackTraceDebug()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.AssetManager
|
||||||
|
import android.util.Log
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.tls.HandshakeCertificates
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
@SuppressLint("CustomX509TrustManager")
|
||||||
|
fun OkHttpClient.Builder.disableCertificateVerification() = also { builder ->
|
||||||
|
runCatching {
|
||||||
|
val trustAllCerts = object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = Unit
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) = Unit
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||||
|
}
|
||||||
|
val sslContext = SSLContext.getInstance("SSL")
|
||||||
|
sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())
|
||||||
|
val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory
|
||||||
|
builder.sslSocketFactory(sslSocketFactory, trustAllCerts)
|
||||||
|
builder.hostnameVerifier { _, _ -> true }
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTraceDebug()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun OkHttpClient.Builder.installExtraCertsificates(context: Context) = also { builder ->
|
||||||
|
val certificatesBuilder = HandshakeCertificates.Builder()
|
||||||
|
.addPlatformTrustedCertificates()
|
||||||
|
val assets = context.assets.list("").orEmpty()
|
||||||
|
for (path in assets) {
|
||||||
|
if (path.endsWith(".pem")) {
|
||||||
|
val cert = loadCert(context, path) ?: continue
|
||||||
|
certificatesBuilder.addTrustedCertificate(cert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val certificates = certificatesBuilder.build()
|
||||||
|
builder.sslSocketFactory(certificates.sslSocketFactory(), certificates.trustManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCert(context: Context, path: String): X509Certificate? = runCatching {
|
||||||
|
val cf = CertificateFactory.getInstance("X.509")
|
||||||
|
context.assets.open(path, AssetManager.ACCESS_STREAMING).use {
|
||||||
|
cf.generateCertificate(it)
|
||||||
|
} as X509Certificate
|
||||||
|
}.onFailure { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}.onSuccess {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.i("ExtraCerts", "Loaded cert $path")
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
@@ -18,6 +18,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
@@ -31,6 +32,7 @@ import org.koitharu.kotatsu.core.util.ext.source
|
|||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
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.parsers.util.mapNotNullToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||||
@@ -90,6 +92,14 @@ class AppShortcutManager @Inject constructor(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getMangaShortcuts(): Set<Long> {
|
||||||
|
val shortcuts = ShortcutManagerCompat.getShortcuts(
|
||||||
|
context,
|
||||||
|
ShortcutManagerCompat.FLAG_MATCH_CACHED or ShortcutManagerCompat.FLAG_MATCH_PINNED or ShortcutManagerCompat.FLAG_MATCH_DYNAMIC,
|
||||||
|
)
|
||||||
|
return shortcuts.mapNotNullToSet { it.id.toLongOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
suspend fun await(): Boolean {
|
suspend fun await(): Boolean {
|
||||||
return shortcutsUpdateJob?.join() != null
|
return shortcutsUpdateJob?.join() != null
|
||||||
@@ -150,7 +160,7 @@ class AppShortcutManager @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat {
|
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat = withContext(Dispatchers.Default) {
|
||||||
val icon = runCatchingCancellable {
|
val icon = runCatchingCancellable {
|
||||||
coil.execute(
|
coil.execute(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
@@ -163,7 +173,7 @@ class AppShortcutManager @Inject constructor(
|
|||||||
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
|
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
|
||||||
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
||||||
)
|
)
|
||||||
return ShortcutInfoCompat.Builder(context, source.name)
|
ShortcutInfoCompat.Builder(context, source.name)
|
||||||
.setShortLabel(source.title)
|
.setShortLabel(source.title)
|
||||||
.setLongLabel(source.title)
|
.setLongLabel(source.title)
|
||||||
.setIcon(icon)
|
.setIcon(icon)
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.os
|
package org.koitharu.kotatsu.core.os
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.cert.CertificateFactory
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -18,29 +11,13 @@ import javax.inject.Singleton
|
|||||||
class AppValidator @Inject constructor(
|
class AppValidator @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
|
@Suppress("NewApi")
|
||||||
val isOriginalApp by lazy {
|
val isOriginalApp by lazy {
|
||||||
getCertificateSHA1Fingerprint() == CERT_SHA1
|
val certificates = mapOf(CERT_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256)
|
||||||
|
PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
@SuppressLint("PackageManagerGetSignatures")
|
|
||||||
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
|
|
||||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
|
|
||||||
val signatures = requireNotNull(packageInfo?.signatures)
|
|
||||||
val cert: ByteArray = signatures.first().toByteArray()
|
|
||||||
val input: InputStream = ByteArrayInputStream(cert)
|
|
||||||
val cf = CertificateFactory.getInstance("X509")
|
|
||||||
val c = cf.generateCertificate(input) as X509Certificate
|
|
||||||
val md: MessageDigest = MessageDigest.getInstance("SHA1")
|
|
||||||
val publicKey: ByteArray = md.digest(c.encoded)
|
|
||||||
return publicKey.byte2HexFormatted()
|
|
||||||
}.onFailure { error ->
|
|
||||||
error.printStackTraceDebug()
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
private const val CERT_SHA256 = "67e15100bb809301783edcb6348fa3bbf83034d91e62868a91053dbd70db3f18"
|
||||||
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import android.net.ConnectivityManager.NetworkCallback
|
|||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
|
import android.os.Build
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.MediatorStateFlow
|
import org.koitharu.kotatsu.core.util.MediatorStateFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.isOnline
|
|
||||||
|
|
||||||
class NetworkState(
|
class NetworkState(
|
||||||
private val connectivityManager: ConnectivityManager,
|
private val connectivityManager: ConnectivityManager,
|
||||||
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline()) {
|
private val settings: AppSettings,
|
||||||
|
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline(settings)) {
|
||||||
|
|
||||||
private val callback = NetworkCallbackImpl()
|
private val callback = NetworkCallbackImpl()
|
||||||
|
|
||||||
@@ -19,7 +21,10 @@ class NetworkState(
|
|||||||
override fun onActive() {
|
override fun onActive() {
|
||||||
invalidate()
|
invalidate()
|
||||||
val request = NetworkRequest.Builder()
|
val request = NetworkRequest.Builder()
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
.build()
|
.build()
|
||||||
connectivityManager.registerNetworkCallback(request, callback)
|
connectivityManager.registerNetworkCallback(request, callback)
|
||||||
}
|
}
|
||||||
@@ -37,7 +42,7 @@ class NetworkState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun invalidate() {
|
private fun invalidate() {
|
||||||
publishValue(connectivityManager.isOnline())
|
publishValue(connectivityManager.isOnline(settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class NetworkCallbackImpl : NetworkCallback() {
|
private inner class NetworkCallbackImpl : NetworkCallback() {
|
||||||
@@ -48,4 +53,27 @@ class NetworkState(
|
|||||||
|
|
||||||
override fun onUnavailable() = invalidate()
|
override fun onUnavailable() = invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
fun ConnectivityManager.isOnline(settings: AppSettings): Boolean {
|
||||||
|
if (settings.isOfflineCheckDisabled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
activeNetwork?.let { isOnline(it) } ?: false
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
activeNetworkInfo?.isConnected == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ConnectivityManager.isOnline(network: Network): Boolean {
|
||||||
|
val capabilities = getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||||
|
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||||
|
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
@@ -18,24 +19,20 @@ import java.util.EnumSet
|
|||||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
override val configKeyDomain: ConfigKey.Domain
|
||||||
get() = ConfigKey.Domain("")
|
get() = ConfigKey.Domain("localhost")
|
||||||
|
|
||||||
override val availableSortOrders: Set<SortOrder>
|
override val availableSortOrders: Set<SortOrder>
|
||||||
get() = EnumSet.allOf(SortOrder::class.java)
|
get() = EnumSet.allOf(SortOrder::class.java)
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getAvailableTags(): Set<MangaTag> {
|
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
||||||
TODO("Not yet implemented")
|
|
||||||
|
private fun stub(manga: Manga?): Nothing {
|
||||||
|
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,22 +4,29 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
|
import androidx.annotation.MainThread
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||||
import org.koitharu.kotatsu.core.util.ext.toList
|
import org.koitharu.kotatsu.core.util.ext.toList
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
@@ -31,13 +38,11 @@ class MangaLoaderContextImpl @Inject constructor(
|
|||||||
) : MangaLoaderContext() {
|
) : MangaLoaderContext() {
|
||||||
|
|
||||||
private var webViewCached: WeakReference<WebView>? = null
|
private var webViewCached: WeakReference<WebView>? = null
|
||||||
|
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
|
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main.immediate) {
|
||||||
val webView = webViewCached?.get() ?: WebView(androidContext).also {
|
val webView = obtainWebView()
|
||||||
it.settings.javaScriptEnabled = true
|
|
||||||
webViewCached = WeakReference(it)
|
|
||||||
}
|
|
||||||
suspendCoroutine { cont ->
|
suspendCoroutine { cont ->
|
||||||
webView.evaluateJavascript(script) { result ->
|
webView.evaluateJavascript(script) { result ->
|
||||||
cont.resume(result?.takeUnless { it == "null" })
|
cont.resume(result?.takeUnless { it == "null" })
|
||||||
@@ -45,6 +50,8 @@ class MangaLoaderContextImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getDefaultUserAgent(): String = webViewUserAgent
|
||||||
|
|
||||||
override fun getConfig(source: MangaSource): MangaSourceConfig {
|
override fun getConfig(source: MangaSource): MangaSourceConfig {
|
||||||
return SourceSettings(androidContext, source)
|
return SourceSettings(androidContext, source)
|
||||||
}
|
}
|
||||||
@@ -60,4 +67,30 @@ class MangaLoaderContextImpl @Inject constructor(
|
|||||||
override fun getPreferredLocales(): List<Locale> {
|
override fun getPreferredLocales(): List<Locale> {
|
||||||
return LocaleListCompat.getAdjustedDefault().toList()
|
return LocaleListCompat.getAdjustedDefault().toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
private fun obtainWebView(): WebView {
|
||||||
|
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||||
|
it.configureForParser(null)
|
||||||
|
webViewCached = WeakReference(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun obtainWebViewUserAgent(): String {
|
||||||
|
val mainDispatcher = Dispatchers.Main.immediate
|
||||||
|
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
||||||
|
obtainWebViewUserAgentImpl()
|
||||||
|
} else {
|
||||||
|
runBlocking(mainDispatcher) {
|
||||||
|
obtainWebViewUserAgentImpl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
private fun obtainWebViewUserAgentImpl() = runCatching {
|
||||||
|
obtainWebView().settings.userAgentString.sanitizeHeaderValue()
|
||||||
|
}.onFailure { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
import androidx.annotation.AnyThread
|
import androidx.annotation.AnyThread
|
||||||
import org.koitharu.kotatsu.core.cache.ContentCache
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
@@ -57,7 +57,7 @@ interface MangaRepository {
|
|||||||
class Factory @Inject constructor(
|
class Factory @Inject constructor(
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
private val loaderContext: MangaLoaderContext,
|
private val loaderContext: MangaLoaderContext,
|
||||||
private val contentCache: ContentCache,
|
private val contentCache: MemoryContentCache,
|
||||||
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.collection.MutableLongSet
|
||||||
import coil.request.CachePolicy
|
import coil.request.CachePolicy
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -12,15 +13,15 @@ import okhttp3.Headers
|
|||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.cache.ContentCache
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
import org.koitharu.kotatsu.core.cache.SafeDeferred
|
import org.koitharu.kotatsu.core.cache.SafeDeferred
|
||||||
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.MultiMutex
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
import org.koitharu.kotatsu.parsers.model.Favicons
|
import org.koitharu.kotatsu.parsers.model.Favicons
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -37,10 +38,14 @@ import java.util.Locale
|
|||||||
|
|
||||||
class RemoteMangaRepository(
|
class RemoteMangaRepository(
|
||||||
private val parser: MangaParser,
|
private val parser: MangaParser,
|
||||||
private val cache: ContentCache,
|
private val cache: MemoryContentCache,
|
||||||
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
||||||
) : MangaRepository, Interceptor {
|
) : MangaRepository, Interceptor {
|
||||||
|
|
||||||
|
private val detailsMutex = MultiMutex<Long>()
|
||||||
|
private val relatedMangaMutex = MultiMutex<Long>()
|
||||||
|
private val pagesMutex = MultiMutex<Long>()
|
||||||
|
|
||||||
override val source: MangaSource
|
override val source: MangaSource
|
||||||
get() = parser.source
|
get() = parser.source
|
||||||
|
|
||||||
@@ -96,7 +101,7 @@ class RemoteMangaRepository(
|
|||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
|
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
|
||||||
cache.getPages(source, chapter.url)?.let { return it }
|
cache.getPages(source, chapter.url)?.let { return it }
|
||||||
val pages = asyncSafe {
|
val pages = asyncSafe {
|
||||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
@@ -104,8 +109,8 @@ class RemoteMangaRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cache.putPages(source, chapter.url, pages)
|
cache.putPages(source, chapter.url, pages)
|
||||||
return pages.await()
|
pages
|
||||||
}
|
}.await()
|
||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
|
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
parser.getPageUrl(page)
|
parser.getPageUrl(page)
|
||||||
@@ -123,16 +128,16 @@ class RemoteMangaRepository(
|
|||||||
parser.getFavicons()
|
parser.getFavicons()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRelated(seed: Manga): List<Manga> {
|
override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
|
||||||
cache.getRelatedManga(source, seed.url)?.let { return it }
|
cache.getRelatedManga(source, seed.url)?.let { return it }
|
||||||
val related = asyncSafe {
|
val related = asyncSafe {
|
||||||
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
|
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
|
||||||
}
|
}
|
||||||
cache.putRelatedManga(source, seed.url, related)
|
cache.putRelatedManga(source, seed.url, related)
|
||||||
return related.await()
|
related
|
||||||
}
|
}.await()
|
||||||
|
|
||||||
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga {
|
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
|
||||||
if (cachePolicy.readEnabled) {
|
if (cachePolicy.readEnabled) {
|
||||||
cache.getDetails(source, manga.url)?.let { return it }
|
cache.getDetails(source, manga.url)?.let { return it }
|
||||||
}
|
}
|
||||||
@@ -144,8 +149,8 @@ class RemoteMangaRepository(
|
|||||||
if (cachePolicy.writeEnabled) {
|
if (cachePolicy.writeEnabled) {
|
||||||
cache.putDetails(source, manga.url, details)
|
cache.putDetails(source, manga.url, details)
|
||||||
}
|
}
|
||||||
return details.await()
|
details
|
||||||
}
|
}.await()
|
||||||
|
|
||||||
suspend fun peekDetails(manga: Manga): Manga? {
|
suspend fun peekDetails(manga: Manga): Manga? {
|
||||||
return cache.getDetails(source, manga.url)
|
return cache.getDetails(source, manga.url)
|
||||||
@@ -170,7 +175,11 @@ class RemoteMangaRepository(
|
|||||||
return getConfig().isSlowdownEnabled
|
return getConfig().isSlowdownEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getConfig() = parser.config as SourceSettings
|
fun invalidateCache() {
|
||||||
|
cache.clear(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getConfig() = parser.config as SourceSettings
|
||||||
|
|
||||||
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
||||||
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
||||||
@@ -189,7 +198,7 @@ class RemoteMangaRepository(
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
val result = ArrayList<MangaPage>(size)
|
val result = ArrayList<MangaPage>(size)
|
||||||
val set = HashSet<Long>(size)
|
val set = MutableLongSet(size)
|
||||||
for (page in this) {
|
for (page in this) {
|
||||||
if (set.add(page.id)) {
|
if (set.add(page.id)) {
|
||||||
result.add(page)
|
result.add(page)
|
||||||
@@ -226,6 +235,5 @@ class RemoteMangaRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
|
private fun Result<*>.isValidResult() = isSuccess && (getOrNull() as? Collection<*>)?.isEmpty() != true
|
||||||
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,10 +170,11 @@ class FaviconFetcher(
|
|||||||
|
|
||||||
class Factory(
|
class Factory(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val okHttpClient: OkHttpClient,
|
okHttpClientLazy: Lazy<OkHttpClient>,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
) : Fetcher.Factory<Uri> {
|
) : Fetcher.Factory<Uri> {
|
||||||
|
|
||||||
|
private val okHttpClient by okHttpClientLazy
|
||||||
private val diskCache = lazy {
|
private val diskCache = lazy {
|
||||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||||
DiskCache.Builder()
|
DiskCache.Builder()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
|
|||||||
import androidx.collection.ArraySet
|
import androidx.collection.ArraySet
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
@@ -31,6 +32,7 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
|
|||||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
|
import java.util.EnumSet
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -70,10 +72,20 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isNavLabelsVisible: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_NAV_LABELS, true)
|
||||||
|
|
||||||
|
val isNavBarPinned: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_NAV_PINNED, false)
|
||||||
|
|
||||||
var gridSize: Int
|
var gridSize: Int
|
||||||
get() = prefs.getInt(KEY_GRID_SIZE, 100)
|
get() = prefs.getInt(KEY_GRID_SIZE, 100)
|
||||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
|
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
|
||||||
|
|
||||||
|
var gridSizePages: Int
|
||||||
|
get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100)
|
||||||
|
set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) }
|
||||||
|
|
||||||
var historyListMode: ListMode
|
var historyListMode: ListMode
|
||||||
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
|
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
|
||||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
|
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
|
||||||
@@ -101,14 +113,21 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val readerPageSwitch: Set<String>
|
var isReaderDoubleOnLandscape: Boolean
|
||||||
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
|
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
||||||
|
|
||||||
|
val isReaderVolumeButtonsEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false)
|
||||||
|
|
||||||
val isReaderZoomButtonsEnabled: Boolean
|
val isReaderZoomButtonsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
|
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
|
||||||
|
|
||||||
val isReaderTapsAdaptive: Boolean
|
val isReaderControlAlwaysLTR: Boolean
|
||||||
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
|
get() = prefs.getBoolean(KEY_READER_CONTROL_LTR, false)
|
||||||
|
|
||||||
|
val isReaderFullscreenEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_READER_FULLSCREEN, true)
|
||||||
|
|
||||||
val isReaderOptimizationEnabled: Boolean
|
val isReaderOptimizationEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
|
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
|
||||||
@@ -117,6 +136,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
||||||
|
|
||||||
|
val isOfflineCheckDisabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
|
||||||
|
|
||||||
var isAllFavouritesVisible: Boolean
|
var isAllFavouritesVisible: Boolean
|
||||||
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
|
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
|
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
|
||||||
@@ -127,6 +149,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isTrackerWifiOnly: Boolean
|
val isTrackerWifiOnly: Boolean
|
||||||
get() = prefs.getBoolean(KEY_TRACKER_WIFI_ONLY, false)
|
get() = prefs.getBoolean(KEY_TRACKER_WIFI_ONLY, false)
|
||||||
|
|
||||||
|
val trackerFrequencyFactor: Float
|
||||||
|
get() = prefs.getString(KEY_TRACKER_FREQUENCY, null)?.toFloatOrNull() ?: 1f
|
||||||
|
|
||||||
val isTrackerNotificationsEnabled: Boolean
|
val isTrackerNotificationsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
||||||
|
|
||||||
@@ -157,6 +182,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
|
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) }
|
set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) }
|
||||||
|
|
||||||
|
var isUpdatedGroupingEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_UPDATED_GROUPING, true)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_UPDATED_GROUPING, value) }
|
||||||
|
|
||||||
|
var isFeedHeaderVisible: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_FEED_HEADER, true)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
|
||||||
|
|
||||||
val isReadingIndicatorsEnabled: Boolean
|
val isReadingIndicatorsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
|
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
|
||||||
|
|
||||||
@@ -167,10 +200,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false)
|
get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) }
|
set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) }
|
||||||
|
|
||||||
var chaptersReverse: Boolean
|
var isChaptersReverse: Boolean
|
||||||
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
|
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) }
|
set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) }
|
||||||
|
|
||||||
|
var isChaptersGridView: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_GRID_VIEW_CHAPTERS, false)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_GRID_VIEW_CHAPTERS, value) }
|
||||||
|
|
||||||
val zoomMode: ZoomMode
|
val zoomMode: ZoomMode
|
||||||
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
|
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
|
||||||
|
|
||||||
@@ -180,11 +217,20 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
var appPassword: String?
|
var appPassword: String?
|
||||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||||
set(value) = prefs.edit {
|
set(value) = prefs.edit {
|
||||||
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
|
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD)
|
||||||
KEY_APP_PASSWORD,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isAppPasswordNumeric: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_APP_PASSWORD_NUMERIC, false)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_APP_PASSWORD_NUMERIC, value) }
|
||||||
|
|
||||||
|
val searchSuggestionTypes: Set<SearchSuggestionType>
|
||||||
|
get() = prefs.getStringSet(KEY_SEARCH_SUGGESTION_TYPES, null)?.let { stringSet ->
|
||||||
|
stringSet.mapNotNullTo(EnumSet.noneOf(SearchSuggestionType::class.java)) { x ->
|
||||||
|
enumValueOf<SearchSuggestionType>(x)
|
||||||
|
}
|
||||||
|
} ?: EnumSet.allOf(SearchSuggestionType::class.java)
|
||||||
|
|
||||||
val isLoggingEnabled: Boolean
|
val isLoggingEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
|
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
|
||||||
|
|
||||||
@@ -204,8 +250,24 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isUnstableUpdatesAllowed: Boolean
|
val isUnstableUpdatesAllowed: Boolean
|
||||||
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
|
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
|
||||||
|
|
||||||
|
val isPagesTabEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_PAGES_TAB, true)
|
||||||
|
|
||||||
val defaultDetailsTab: Int
|
val defaultDetailsTab: Int
|
||||||
get() = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
|
get() = if (isPagesTabEnabled) {
|
||||||
|
val raw = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull() ?: -1
|
||||||
|
if (raw == -1) {
|
||||||
|
lastDetailsTab
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
}.coerceIn(0, 2)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastDetailsTab: Int
|
||||||
|
get() = prefs.getInt(KEY_DETAILS_LAST_TAB, 0)
|
||||||
|
set(value) = prefs.edit { putInt(KEY_DETAILS_LAST_TAB, value) }
|
||||||
|
|
||||||
val isContentPrefetchEnabled: Boolean
|
val isContentPrefetchEnabled: Boolean
|
||||||
get() {
|
get() {
|
||||||
@@ -222,7 +284,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) }
|
set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) }
|
||||||
|
|
||||||
var isSourcesGridMode: Boolean
|
var isSourcesGridMode: Boolean
|
||||||
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
|
get() = prefs.getBoolean(KEY_SOURCES_GRID, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
|
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
|
||||||
|
|
||||||
val isNewSourcesTipEnabled: Boolean
|
val isNewSourcesTipEnabled: Boolean
|
||||||
@@ -266,6 +328,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isDownloadsWiFiOnly: Boolean
|
val isDownloadsWiFiOnly: Boolean
|
||||||
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
|
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
|
||||||
|
|
||||||
|
val preferredDownloadFormat: DownloadFormat
|
||||||
|
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
|
||||||
|
|
||||||
var isSuggestionsEnabled: Boolean
|
var isSuggestionsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
|
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
|
||||||
@@ -347,15 +412,27 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
|
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
|
||||||
|
|
||||||
var historySortOrder: ListSortOrder
|
var historySortOrder: ListSortOrder
|
||||||
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.UPDATED)
|
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.LAST_READ)
|
||||||
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
|
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
|
||||||
|
|
||||||
|
var allFavoritesSortOrder: ListSortOrder
|
||||||
|
get() = prefs.getEnumValue(KEY_FAVORITES_ORDER, ListSortOrder.NEWEST)
|
||||||
|
set(value) = prefs.edit { putEnumValue(KEY_FAVORITES_ORDER, value) }
|
||||||
|
|
||||||
val isRelatedMangaEnabled: Boolean
|
val isRelatedMangaEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_RELATED_MANGA, true)
|
get() = prefs.getBoolean(KEY_RELATED_MANGA, true)
|
||||||
|
|
||||||
val isWebtoonZoomEnable: Boolean
|
val isWebtoonZoomEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
|
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
|
||||||
|
|
||||||
|
var isWebtoonGapsEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_GAPS, value) }
|
||||||
|
|
||||||
|
@get:FloatRange(from = 0.0, to = 0.5)
|
||||||
|
val defaultWebtoonZoomOut: Float
|
||||||
|
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
|
||||||
|
|
||||||
@get:FloatRange(from = 0.0, to = 1.0)
|
@get:FloatRange(from = 0.0, to = 1.0)
|
||||||
var readerAutoscrollSpeed: Float
|
var readerAutoscrollSpeed: Float
|
||||||
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
|
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
|
||||||
@@ -391,6 +468,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
|
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
|
||||||
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
|
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
|
||||||
|
|
||||||
|
val isReadingTimeEstimationEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_READING_TIME, true)
|
||||||
|
|
||||||
|
val isPagesSavingAskEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true)
|
||||||
|
|
||||||
|
val isStatsEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_STATS_ENABLED, false)
|
||||||
|
|
||||||
|
val isAutoLocalChaptersCleanupEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
|
||||||
|
|
||||||
fun isTipEnabled(tip: String): Boolean {
|
fun isTipEnabled(tip: String): Boolean {
|
||||||
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
||||||
}
|
}
|
||||||
@@ -403,6 +492,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
|
prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getPagesSaveDir(context: Context): DocumentFile? =
|
||||||
|
prefs.getString(KEY_PAGES_SAVE_DIR, null)?.toUriOrNull()?.let {
|
||||||
|
DocumentFile.fromTreeUri(context, it)?.takeIf { it.canWrite() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPagesSaveDir(uri: Uri?) {
|
||||||
|
prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||||
}
|
}
|
||||||
@@ -449,7 +547,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val PAGE_SWITCH_TAPS = "taps"
|
|
||||||
const val PAGE_SWITCH_VOLUME_KEYS = "volume"
|
const val PAGE_SWITCH_VOLUME_KEYS = "volume"
|
||||||
|
|
||||||
const val TRACK_HISTORY = "history"
|
const val TRACK_HISTORY = "history"
|
||||||
@@ -463,19 +560,27 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_COLOR_THEME = "color_theme"
|
const val KEY_COLOR_THEME = "color_theme"
|
||||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||||
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
||||||
|
const val KEY_OFFLINE_DISABLED = "no_offline"
|
||||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||||
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
|
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
|
||||||
const val KEY_COOKIES_CLEAR = "cookies_clear"
|
const val KEY_COOKIES_CLEAR = "cookies_clear"
|
||||||
|
const val KEY_CHAPTERS_CLEAR = "chapters_clear"
|
||||||
|
const val KEY_CHAPTERS_CLEAR_AUTO = "chapters_clear_auto"
|
||||||
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
|
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
|
||||||
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"
|
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"
|
||||||
const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear"
|
const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear"
|
||||||
const val KEY_GRID_SIZE = "grid_size"
|
const val KEY_GRID_SIZE = "grid_size"
|
||||||
|
const val KEY_GRID_SIZE_PAGES = "grid_size_pages"
|
||||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||||
const val KEY_READER_SWITCHERS = "reader_switchers"
|
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
||||||
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
||||||
|
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||||
|
const val KEY_READER_FULLSCREEN = "reader_fullscreen"
|
||||||
|
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
|
||||||
const val KEY_TRACKER_ENABLED = "tracker_enabled"
|
const val KEY_TRACKER_ENABLED = "tracker_enabled"
|
||||||
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
|
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
|
||||||
|
const val KEY_TRACKER_FREQUENCY = "tracker_freq"
|
||||||
const val KEY_TRACK_SOURCES = "track_sources"
|
const val KEY_TRACK_SOURCES = "track_sources"
|
||||||
const val KEY_TRACK_CATEGORIES = "track_categories"
|
const val KEY_TRACK_CATEGORIES = "track_categories"
|
||||||
const val KEY_TRACK_WARNING = "track_warning"
|
const val KEY_TRACK_WARNING = "track_warning"
|
||||||
@@ -489,6 +594,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_READER_MODE = "reader_mode"
|
const val KEY_READER_MODE = "reader_mode"
|
||||||
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
||||||
const val KEY_APP_PASSWORD = "app_password"
|
const val KEY_APP_PASSWORD = "app_password"
|
||||||
|
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||||
const val KEY_PROTECT_APP = "protect_app"
|
const val KEY_PROTECT_APP = "protect_app"
|
||||||
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
|
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
|
||||||
const val KEY_APP_VERSION = "app_version"
|
const val KEY_APP_VERSION = "app_version"
|
||||||
@@ -500,8 +606,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
|
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
|
||||||
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
|
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
|
||||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||||
|
const val KEY_UPDATED_GROUPING = "updated_grouping"
|
||||||
const val KEY_READING_INDICATORS = "reading_indicators"
|
const val KEY_READING_INDICATORS = "reading_indicators"
|
||||||
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
||||||
|
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
|
||||||
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
||||||
const val KEY_PAGES_NUMBERS = "pages_numbers"
|
const val KEY_PAGES_NUMBERS = "pages_numbers"
|
||||||
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
|
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
|
||||||
@@ -514,7 +622,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_SHIKIMORI = "shikimori"
|
const val KEY_SHIKIMORI = "shikimori"
|
||||||
const val KEY_ANILIST = "anilist"
|
const val KEY_ANILIST = "anilist"
|
||||||
const val KEY_MAL = "mal"
|
const val KEY_MAL = "mal"
|
||||||
|
const val KEY_KITSU = "kitsu"
|
||||||
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
|
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
|
||||||
|
const val KEY_DOWNLOADS_FORMAT = "downloads_format"
|
||||||
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
|
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
|
||||||
const val KEY_DOH = "doh"
|
const val KEY_DOH = "doh"
|
||||||
const val KEY_EXIT_CONFIRM = "exit_confirm"
|
const val KEY_EXIT_CONFIRM = "exit_confirm"
|
||||||
@@ -526,11 +636,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_READER_BACKGROUND = "reader_background"
|
const val KEY_READER_BACKGROUND = "reader_background"
|
||||||
const val KEY_READER_SCREEN_ON = "reader_screen_on"
|
const val KEY_READER_SCREEN_ON = "reader_screen_on"
|
||||||
const val KEY_SHORTCUTS = "dynamic_shortcuts"
|
const val KEY_SHORTCUTS = "dynamic_shortcuts"
|
||||||
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
|
const val KEY_READER_TAP_ACTIONS = "reader_tap_actions"
|
||||||
const val KEY_READER_OPTIMIZE = "reader_optimize"
|
const val KEY_READER_OPTIMIZE = "reader_optimize"
|
||||||
const val KEY_LOCAL_LIST_ORDER = "local_order"
|
const val KEY_LOCAL_LIST_ORDER = "local_order"
|
||||||
const val KEY_HISTORY_ORDER = "history_order"
|
const val KEY_HISTORY_ORDER = "history_order"
|
||||||
|
const val KEY_FAVORITES_ORDER = "fav_order"
|
||||||
|
const val KEY_WEBTOON_GAPS = "webtoon_gaps"
|
||||||
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
|
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
|
||||||
|
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
|
||||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||||
const val KEY_APP_LOCALE = "app_locale"
|
const val KEY_APP_LOCALE = "app_locale"
|
||||||
const val KEY_LOGGING_ENABLED = "logging"
|
const val KEY_LOGGING_ENABLED = "logging"
|
||||||
@@ -554,6 +667,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_DISABLE_NSFW = "no_nsfw"
|
const val KEY_DISABLE_NSFW = "no_nsfw"
|
||||||
const val KEY_RELATED_MANGA = "related_manga"
|
const val KEY_RELATED_MANGA = "related_manga"
|
||||||
const val KEY_NAV_MAIN = "nav_main"
|
const val KEY_NAV_MAIN = "nav_main"
|
||||||
|
const val KEY_NAV_LABELS = "nav_labels"
|
||||||
|
const val KEY_NAV_PINNED = "nav_pinned"
|
||||||
const val KEY_32BIT_COLOR = "enhanced_colors"
|
const val KEY_32BIT_COLOR = "enhanced_colors"
|
||||||
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
||||||
const val KEY_SOURCES_CATALOG = "sources_catalog"
|
const val KEY_SOURCES_CATALOG = "sources_catalog"
|
||||||
@@ -562,10 +677,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_CF_INVERTED = "cf_inverted"
|
const val KEY_CF_INVERTED = "cf_inverted"
|
||||||
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
||||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||||
|
const val KEY_PAGES_TAB = "pages_tab"
|
||||||
const val KEY_DETAILS_TAB = "details_tab"
|
const val KEY_DETAILS_TAB = "details_tab"
|
||||||
|
const val KEY_DETAILS_LAST_TAB = "details_last_tab"
|
||||||
// About
|
const val KEY_READING_TIME = "reading_time"
|
||||||
|
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
||||||
|
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
||||||
|
const val KEY_STATS_ENABLED = "stats_on"
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||||
|
const val KEY_FEED_HEADER = "feed_header"
|
||||||
|
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
enum class DownloadFormat {
|
||||||
|
|
||||||
|
AUTOMATIC,
|
||||||
|
SINGLE_CBZ,
|
||||||
|
MULTIPLE_CBZ,
|
||||||
|
}
|
||||||
@@ -4,13 +4,12 @@ import androidx.annotation.DrawableRes
|
|||||||
import androidx.annotation.IdRes
|
import androidx.annotation.IdRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
enum class NavItem(
|
enum class NavItem(
|
||||||
@IdRes val id: Int,
|
@IdRes val id: Int,
|
||||||
@StringRes val title: Int,
|
@StringRes val title: Int,
|
||||||
@DrawableRes val icon: Int,
|
@DrawableRes val icon: Int,
|
||||||
) : ListModel {
|
) {
|
||||||
|
|
||||||
HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector),
|
HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector),
|
||||||
FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector),
|
FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector),
|
||||||
@@ -18,16 +17,13 @@ enum class NavItem(
|
|||||||
EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector),
|
EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector),
|
||||||
SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector),
|
SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector),
|
||||||
FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector),
|
FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector),
|
||||||
|
UPDATED(R.id.nav_updated, R.string.updated, R.drawable.ic_updated_selector),
|
||||||
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
|
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
|
||||||
;
|
;
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is NavItem && ordinal == other.ordinal
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isAvailable(settings: AppSettings): Boolean = when (this) {
|
fun isAvailable(settings: AppSettings): Boolean = when (this) {
|
||||||
SUGGESTIONS -> settings.isSuggestionsEnabled
|
SUGGESTIONS -> settings.isSuggestionsEnabled
|
||||||
FEED -> settings.isTrackerEnabled
|
UPDATED, FEED -> settings.isTrackerEnabled
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ enum class ReaderMode(val id: Int) {
|
|||||||
|
|
||||||
STANDARD(1),
|
STANDARD(1),
|
||||||
REVERSED(3),
|
REVERSED(3),
|
||||||
WEBTOON(2);
|
VERTICAL(4),
|
||||||
|
WEBTOON(2),
|
||||||
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
enum class SearchSuggestionType(
|
||||||
|
@StringRes val titleResId: Int,
|
||||||
|
) {
|
||||||
|
|
||||||
|
GENRES(R.string.genres),
|
||||||
|
QUERIES_RECENT(R.string.recent_queries),
|
||||||
|
QUERIES_SUGGEST(R.string.suggested_queries),
|
||||||
|
MANGA(R.string.content_type_manga),
|
||||||
|
SOURCES(R.string.remote_sources),
|
||||||
|
AUTHORS(R.string.authors),
|
||||||
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
package org.koitharu.kotatsu.core.prefs
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import okhttp3.internal.isSensitiveHeader
|
||||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||||
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.model.SortOrder
|
||||||
|
|
||||||
private const val KEY_SORT_ORDER = "sort_order"
|
|
||||||
private const val KEY_SLOWDOWN = "slowdown"
|
|
||||||
|
|
||||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||||
|
|
||||||
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
|
||||||
@@ -27,9 +27,13 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
|||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T> get(key: ConfigKey<T>): T {
|
override fun <T> get(key: ConfigKey<T>): T {
|
||||||
return when (key) {
|
return when (key) {
|
||||||
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue)
|
||||||
|
.ifNullOrEmpty { key.defaultValue }
|
||||||
|
.sanitizeHeaderValue()
|
||||||
|
|
||||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
||||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||||
|
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||||
} as T
|
} as T
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +41,22 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
|||||||
when (key) {
|
when (key) {
|
||||||
is ConfigKey.Domain -> putString(key.key, value as String?)
|
is ConfigKey.Domain -> putString(key.key, value as String?)
|
||||||
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
|
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
|
||||||
is ConfigKey.UserAgent -> putString(key.key, value as String?)
|
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
|
||||||
|
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun subscribe(listener: OnSharedPreferenceChangeListener) {
|
||||||
|
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsubscribe(listener: OnSharedPreferenceChangeListener) {
|
||||||
|
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val KEY_SORT_ORDER = "sort_order"
|
||||||
|
const val KEY_SLOWDOWN = "slowdown"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,32 +3,28 @@ package org.koitharu.kotatsu.core.ui
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.widget.Toast
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.appcompat.widget.ActionBarContextView
|
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.graphics.ColorUtils
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||||
import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint
|
|
||||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||||
|
|
||||||
@Suppress("LeakingThis")
|
@Suppress("LeakingThis")
|
||||||
abstract class BaseActivity<B : ViewBinding> :
|
abstract class BaseActivity<B : ViewBinding> :
|
||||||
@@ -58,11 +54,11 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
if (isAmoledTheme) {
|
if (isAmoledTheme) {
|
||||||
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
||||||
}
|
}
|
||||||
|
putDataToExtras(intent)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
insetsDelegate.handleImeInsets = true
|
insetsDelegate.handleImeInsets = true
|
||||||
insetsDelegate.addInsetsListener(this)
|
insetsDelegate.addInsetsListener(this)
|
||||||
putDataToExtras(intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -70,7 +66,7 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
onBackPressedDispatcher.addCallback(actionModeDelegate)
|
onBackPressedDispatcher.addCallback(actionModeDelegate)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
putDataToExtras(intent)
|
putDataToExtras(intent)
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
}
|
}
|
||||||
@@ -96,6 +92,9 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSupportNavigateUp(): Boolean {
|
override fun onSupportNavigateUp(): Boolean {
|
||||||
|
if (supportFragmentManager.popBackStackImmediate()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
dispatchNavigateUp()
|
dispatchNavigateUp()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -121,32 +120,13 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
@CallSuper
|
@CallSuper
|
||||||
override fun onSupportActionModeStarted(mode: ActionMode) {
|
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||||
super.onSupportActionModeStarted(mode)
|
super.onSupportActionModeStarted(mode)
|
||||||
actionModeDelegate.onSupportActionModeStarted(mode)
|
actionModeDelegate.onSupportActionModeStarted(mode, window)
|
||||||
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
ColorUtils.compositeColors(
|
|
||||||
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
|
|
||||||
getThemeColor(R.attr.m3ColorBackground),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ContextCompat.getColor(this, R.color.kotatsu_m3_background)
|
|
||||||
}
|
|
||||||
val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
|
|
||||||
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
|
||||||
findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply {
|
|
||||||
setBackgroundColor(actionModeColor)
|
|
||||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
topMargin = insets.top
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defaultStatusBarColor = window.statusBarColor
|
|
||||||
window.statusBarColor = actionModeColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
override fun onSupportActionModeFinished(mode: ActionMode) {
|
override fun onSupportActionModeFinished(mode: ActionMode) {
|
||||||
super.onSupportActionModeFinished(mode)
|
super.onSupportActionModeFinished(mode)
|
||||||
actionModeDelegate.onSupportActionModeFinished(mode)
|
actionModeDelegate.onSupportActionModeFinished(mode, window)
|
||||||
window.statusBarColor = defaultStatusBarColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun dispatchNavigateUp() {
|
protected open fun dispatchNavigateUp() {
|
||||||
@@ -164,6 +144,27 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean {
|
||||||
|
return try {
|
||||||
|
setContentView(viewBindingProducer())
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e.isWebViewUnavailable()) {
|
||||||
|
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
|
||||||
|
finishAfterTransition()
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface BaseActivityEntryPoint {
|
||||||
|
val settings: AppSettings
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val EXTRA_DATA = "data"
|
const val EXTRA_DATA = "data"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
|
|||||||
with(window) {
|
with(window) {
|
||||||
systemUiController = SystemUiController(this)
|
systemUiController = SystemUiController(this)
|
||||||
statusBarColor = Color.TRANSPARENT
|
statusBarColor = Color.TRANSPARENT
|
||||||
navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
|
||||||
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
|
ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim)
|
||||||
} else {
|
} else {
|
||||||
Color.TRANSPARENT
|
Color.TRANSPARENT
|
||||||
@@ -29,7 +29,6 @@ abstract class BaseFullscreenActivity<B : ViewBinding> :
|
|||||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
|
||||||
systemUiController.setSystemUiVisible(true)
|
systemUiController.setSystemUiVisible(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
|
|||||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.asExecutor
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
|
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
@@ -28,11 +34,33 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addListListener(listListener: ListListener<T>) {
|
fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> {
|
||||||
differ.addListListener(listListener)
|
differ.addListListener(listListener)
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeListListener(listListener: ListListener<T>) {
|
fun removeListListener(listListener: ListListener<T>) {
|
||||||
differ.removeListListener(listListener)
|
differ.removeListListener(listListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun findHeader(position: Int): ListHeader? {
|
||||||
|
val snapshot = items
|
||||||
|
for (i in (0..position).reversed()) {
|
||||||
|
val item = snapshot.getOrNull(i) ?: continue
|
||||||
|
if (item is ListHeader) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeItems(): Flow<List<T>> = callbackFlow {
|
||||||
|
val listListener = ListListener<T> { _, list ->
|
||||||
|
trySendBlocking(list)
|
||||||
|
}
|
||||||
|
addListListener(listListener)
|
||||||
|
awaitClose { removeListListener(listListener) }
|
||||||
|
}.onStart {
|
||||||
|
emit(items)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ abstract class BaseViewModel : ViewModel() {
|
|||||||
errorEvent.call(error)
|
errorEvent.call(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected inline suspend fun <T> withLoading(block: () -> T): T = try {
|
||||||
|
loadingCounter.increment()
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
loadingCounter.decrement()
|
||||||
|
}
|
||||||
|
|
||||||
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||||
|
|
||||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
@@ -39,8 +41,10 @@ abstract class CoroutineIntentService : BaseService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
protected abstract suspend fun processIntent(startId: Int, intent: Intent)
|
protected abstract suspend fun processIntent(startId: Int, intent: Intent)
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
protected abstract fun onError(startId: Int, error: Throwable)
|
protected abstract fun onError(startId: Int, error: Throwable)
|
||||||
|
|
||||||
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
|
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ class RecyclerViewAlertDialog private constructor(
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setNeutralButton(
|
||||||
|
@StringRes textId: Int,
|
||||||
|
listener: DialogInterface.OnClickListener,
|
||||||
|
): Builder<T> {
|
||||||
|
delegate.setNeutralButton(textId, listener)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
fun setCancelable(isCancelable: Boolean): Builder<T> {
|
fun setCancelable(isCancelable: Boolean): Builder<T> {
|
||||||
delegate.setCancelable(isCancelable)
|
delegate.setCancelable(isCancelable)
|
||||||
return this
|
return this
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
|
import android.animation.TimeAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.ColorFilter
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||||
|
import com.google.android.material.animation.ArgbEvaluatorCompat
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
|
import kotlin.math.abs
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, TimeAnimator.TimeListener {
|
||||||
|
|
||||||
|
private val colorLow = context.getThemeColor(materialR.attr.colorSurfaceContainerLowest)
|
||||||
|
private val colorHigh = context.getThemeColor(materialR.attr.colorSurfaceContainerHighest)
|
||||||
|
private var currentColor: Int = colorLow
|
||||||
|
private val interpolator = FastOutSlowInInterpolator()
|
||||||
|
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
|
||||||
|
private val timeAnimator = TimeAnimator()
|
||||||
|
|
||||||
|
init {
|
||||||
|
timeAnimator.setTimeListener(this)
|
||||||
|
updateColor()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas) {
|
||||||
|
if (!isRunning && period > 0) {
|
||||||
|
updateColor()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
canvas.drawColor(currentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAlpha(alpha: Int) {
|
||||||
|
// this.alpha = alpha FIXME coil's crossfade
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||||
|
|
||||||
|
@Suppress("DeprecatedCallableAddReplaceWith")
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun getOpacity(): Int = PixelFormat.OPAQUE
|
||||||
|
|
||||||
|
override fun getAlpha(): Int = 255
|
||||||
|
|
||||||
|
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
|
||||||
|
callback?.also {
|
||||||
|
updateColor()
|
||||||
|
it.invalidateDrawable(this)
|
||||||
|
} ?: stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
timeAnimator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
timeAnimator.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isRunning(): Boolean = timeAnimator.isStarted
|
||||||
|
|
||||||
|
private fun updateColor() {
|
||||||
|
if (period <= 0f) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val ph = period / 2
|
||||||
|
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
|
||||||
|
currentColor = ArgbEvaluatorCompat.getInstance()
|
||||||
|
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import coil.target.GenericViewTarget
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
|
||||||
|
class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() {
|
||||||
|
|
||||||
|
override var drawable: Drawable?
|
||||||
|
get() = view.chipIcon
|
||||||
|
set(value) {
|
||||||
|
view.chipIcon = value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import android.view.ViewGroup
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import coil.size.Dimension
|
import coil.size.Dimension
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import coil.size.SizeResolver
|
import coil.size.ViewSizeResolver
|
||||||
import kotlinx.coroutines.CancellableContinuation
|
import kotlinx.coroutines.CancellableContinuation
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
@@ -16,24 +16,24 @@ private const val ASPECT_RATIO_HEIGHT = 18f
|
|||||||
private const val ASPECT_RATIO_WIDTH = 13f
|
private const val ASPECT_RATIO_WIDTH = 13f
|
||||||
|
|
||||||
class CoverSizeResolver(
|
class CoverSizeResolver(
|
||||||
private val imageView: ImageView,
|
override val view: ImageView,
|
||||||
) : SizeResolver {
|
) : ViewSizeResolver<ImageView> {
|
||||||
|
|
||||||
override suspend fun size(): Size {
|
override suspend fun size(): Size {
|
||||||
getSize()?.let { return it }
|
getSize()?.let { return it }
|
||||||
return suspendCancellableCoroutine { cont ->
|
return suspendCancellableCoroutine { cont ->
|
||||||
val layoutListener = LayoutListener(cont)
|
val layoutListener = LayoutListener(cont)
|
||||||
imageView.addOnLayoutChangeListener(layoutListener)
|
view.addOnLayoutChangeListener(layoutListener)
|
||||||
cont.invokeOnCancellation {
|
cont.invokeOnCancellation {
|
||||||
imageView.removeOnLayoutChangeListener(layoutListener)
|
view.removeOnLayoutChangeListener(layoutListener)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSize(): Size? {
|
private fun getSize(): Size? {
|
||||||
val lp = imageView.layoutParams
|
val lp = view.layoutParams
|
||||||
var width = getDimension(lp.width, imageView.width, imageView.paddingLeft + imageView.paddingRight)
|
var width = getDimension(lp.width, view.width, view.paddingLeft + view.paddingRight)
|
||||||
var height = getDimension(lp.height, imageView.height, imageView.paddingTop + imageView.paddingBottom)
|
var height = getDimension(lp.height, view.height, view.paddingTop + view.paddingBottom)
|
||||||
if (width == null && height == null) {
|
if (width == null && height == null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ import android.graphics.RectF
|
|||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import androidx.annotation.StyleRes
|
import androidx.annotation.StyleRes
|
||||||
import androidx.core.content.withStyledAttributes
|
import androidx.core.content.withStyledAttributes
|
||||||
import androidx.core.graphics.ColorUtils
|
|
||||||
import androidx.core.graphics.withClip
|
import androidx.core.graphics.withClip
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import kotlin.math.absoluteValue
|
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||||
|
|
||||||
class FaviconDrawable(
|
class FaviconDrawable(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -44,7 +43,7 @@ class FaviconDrawable(
|
|||||||
}
|
}
|
||||||
paint.textAlign = Paint.Align.CENTER
|
paint.textAlign = Paint.Align.CENTER
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
colorForeground = MaterialColors.harmonize(colorOfString(name), colorBackground)
|
colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun draw(canvas: Canvas) {
|
override fun draw(canvas: Canvas) {
|
||||||
@@ -104,9 +103,4 @@ class FaviconDrawable(
|
|||||||
paint.getTextBounds(text, 0, text.length, tempRect)
|
paint.getTextBounds(text, 0, text.length, tempRect)
|
||||||
return testTextSize * width / tempRect.width()
|
return testTextSize * width / tempRect.width()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun colorOfString(str: String): Int {
|
|
||||||
val hue = (str.hashCode() % 360).absoluteValue.toFloat()
|
|
||||||
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class FitHeightGridLayoutManager : GridLayoutManager {
|
|||||||
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
||||||
val parentBottom = height - paddingBottom
|
val parentBottom = height - paddingBottom
|
||||||
val offset = parentBottom - bottom
|
val offset = parentBottom - bottom
|
||||||
super.layoutDecoratedWithMargins(child, left, top + offset, right, bottom + offset)
|
super.layoutDecoratedWithMargins(child, left, top, right, bottom + offset)
|
||||||
} else {
|
} else {
|
||||||
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user