Compare commits
809 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be012f631a | ||
|
|
0165f43603 | ||
|
|
55801a1488 | ||
|
|
77103f016f | ||
|
|
6b6719a259 | ||
|
|
822642abb0 | ||
|
|
260745fb95 | ||
|
|
024ec0388f | ||
|
|
5345998eec | ||
|
|
3d56190e71 | ||
|
|
954431d0a5 | ||
|
|
afec63b443 | ||
|
|
ac5b29c35a | ||
|
|
59f5578b66 | ||
|
|
391dbb4237 | ||
|
|
7d4505eb78 | ||
|
|
e6ceb20cf7 | ||
|
|
8004f8c093 | ||
|
|
61bf2abb6c | ||
|
|
d9612f3427 | ||
|
|
435c3824f7 | ||
|
|
c846693570 | ||
|
|
123937cd01 | ||
|
|
9f56554313 | ||
|
|
f8687bb697 | ||
|
|
43d3a2cc6a | ||
|
|
a95db6ed21 | ||
|
|
fd0bb57338 | ||
|
|
6b94bc2632 | ||
|
|
c8b91599c6 | ||
|
|
3a8b0f9e93 | ||
|
|
17a0725666 | ||
|
|
3be7848ad9 | ||
|
|
08202c11a3 | ||
|
|
5ef907d046 | ||
|
|
c3776ea3c6 | ||
|
|
a624bffea3 | ||
|
|
8f38b4fe30 | ||
|
|
71a2de5358 | ||
|
|
5478f8fb59 | ||
|
|
5155c9a33d | ||
|
|
f7a461a9d8 | ||
|
|
3a02d22e02 | ||
|
|
2b8a29e2a6 | ||
|
|
bc68441585 | ||
|
|
1cc51b6a88 | ||
|
|
fd5aca7252 | ||
|
|
e447245fac | ||
|
|
5af0ee1c69 | ||
|
|
c02d1641ab | ||
|
|
f55c525c8a | ||
|
|
a42fc87a9a | ||
|
|
6b6905fd71 | ||
|
|
b7f57856db | ||
|
|
1d6d626b62 | ||
|
|
d93ff92cc9 | ||
|
|
8eda113f3b | ||
|
|
3916c2619e | ||
|
|
1d3e8e55ca | ||
|
|
2c3b4f29eb | ||
|
|
ee530002b6 | ||
|
|
59d530e0dc | ||
|
|
52a132caed | ||
|
|
379d2dd8d4 | ||
|
|
f8cefa3e8d | ||
|
|
5e1eda850c | ||
|
|
18cc0ad0fb | ||
|
|
11dd49c626 | ||
|
|
2ad8ab0258 | ||
|
|
4f8c5325a4 | ||
|
|
6e181a59a3 | ||
|
|
7a7d20dbf4 | ||
|
|
83d5f8e378 | ||
|
|
5ac9bad728 | ||
|
|
a090965a2d | ||
|
|
1e376754bc | ||
|
|
2cdbe52056 | ||
|
|
1e09ac3ecb | ||
|
|
acc76c931a | ||
|
|
59c12d35c1 | ||
|
|
0e3cad1af1 | ||
|
|
ba8766b32d | ||
|
|
35421cb71e | ||
|
|
8cecd9a0e2 | ||
|
|
523057f3e1 | ||
|
|
337d196bc3 | ||
|
|
c3b4c032bb | ||
|
|
4590c753ed | ||
|
|
9733101f0c | ||
|
|
8cd71cc98d | ||
|
|
42748d9c98 | ||
|
|
8043574314 | ||
|
|
44d1fdb9d3 | ||
|
|
bc7054de4a | ||
|
|
4971e8ab0f | ||
|
|
df038b1edb | ||
|
|
7e7aabc1d1 | ||
|
|
9605ff89fb | ||
|
|
4ed177d29f | ||
|
|
61cefefd10 | ||
|
|
9f965c5269 | ||
|
|
0c713cb799 | ||
|
|
6d3f8cbb3b | ||
|
|
05739bb5b3 | ||
|
|
47f0bbee17 | ||
|
|
dd77926dcb | ||
|
|
1b76f21507 | ||
|
|
fe21af5443 | ||
|
|
0b0373021e | ||
|
|
d641e7933d | ||
|
|
d8efe374a8 | ||
|
|
506a8b6e90 | ||
|
|
d81173bf76 | ||
|
|
896452a096 | ||
|
|
35aa4d5e8f | ||
|
|
4d4c9c7a48 | ||
|
|
b667e32598 | ||
|
|
c987fc234b | ||
|
|
8142a6811b | ||
|
|
3e36e1e11c | ||
|
|
30aaca6341 | ||
|
|
43b34a7bca | ||
|
|
b23008d0ae | ||
|
|
5a368b27bb | ||
|
|
fe3f95d160 | ||
|
|
de1a297338 | ||
|
|
d6350afe3a | ||
|
|
ec048c70f1 | ||
|
|
282c1b51f7 | ||
|
|
d6b6ce1bcd | ||
|
|
f48444dcf6 | ||
|
|
15ba766643 | ||
|
|
a0dbbcb350 | ||
|
|
f72bba9557 | ||
|
|
207791aa3e | ||
|
|
6319997716 | ||
|
|
b70c1da54b | ||
|
|
621cb19c5b | ||
|
|
b528b7b3c1 | ||
|
|
9a1bb6f6fc | ||
|
|
37f9c4b9f6 | ||
|
|
d0084e50e7 | ||
|
|
088576cc9d | ||
|
|
f0ba42b518 | ||
|
|
33366e63db | ||
|
|
0f39b313c0 | ||
|
|
abcbb940d3 | ||
|
|
f4fec709fc | ||
|
|
a8c6f6a1ce | ||
|
|
3a6794a50e | ||
|
|
aea3328f2d | ||
|
|
c225e90626 | ||
|
|
de8500d705 | ||
|
|
2d41cf14e2 | ||
|
|
1777528fcb | ||
|
|
43618ed224 | ||
|
|
2229439547 | ||
|
|
98abffa67b | ||
|
|
aafefffc27 | ||
|
|
add5c7dc17 | ||
|
|
40584cb6f5 | ||
|
|
e549d141a4 | ||
|
|
4199f54241 | ||
|
|
e511c3cc97 | ||
|
|
33dbca1bc9 | ||
|
|
40778a88dd | ||
|
|
3e8e423962 | ||
|
|
881473f495 | ||
|
|
dc8ecf2b12 | ||
|
|
19d7a98968 | ||
|
|
b2538065a9 | ||
|
|
ebf37e3d08 | ||
|
|
8c37135a40 | ||
|
|
ca716f9a34 | ||
|
|
112ac70648 | ||
|
|
694e4d4baf | ||
|
|
e74afa06a1 | ||
|
|
4494cc1888 | ||
|
|
b13d9078d4 | ||
|
|
5999301de8 | ||
|
|
601b4016e6 | ||
|
|
4d13b8f7b0 | ||
|
|
6b95ec829e | ||
|
|
1c658fa3c3 | ||
|
|
32a8a8fed2 | ||
|
|
c63e0542a0 | ||
|
|
dea779fc4b | ||
|
|
339b8f7311 | ||
|
|
3c087dde11 | ||
|
|
dd1223229d | ||
|
|
0b393b0e81 | ||
|
|
b24cac9305 | ||
|
|
dc92723526 | ||
|
|
203020e100 | ||
|
|
05eac1eabd | ||
|
|
c7b720ec91 | ||
|
|
58026b6fc0 | ||
|
|
ba2f9dc16c | ||
|
|
97afa29785 | ||
|
|
97f2ff3bbd | ||
|
|
14f185393b | ||
|
|
fc7f5f2cf9 | ||
|
|
4088f50120 | ||
|
|
afa18e086c | ||
|
|
a8cfb3521c | ||
|
|
7047ee6155 | ||
|
|
957b12f338 | ||
|
|
679b1fd2f2 | ||
|
|
367a917c56 | ||
|
|
ed7fdb32a1 | ||
|
|
e8f0aa8388 | ||
|
|
7404612a84 | ||
|
|
512069ca3e | ||
|
|
b53b8eefa3 | ||
|
|
f7509b09c1 | ||
|
|
fbff0ab027 | ||
|
|
17656233ef | ||
|
|
18466a2c1a | ||
|
|
2797ea6a99 | ||
|
|
b4a298ea55 | ||
|
|
f811eeebc9 | ||
|
|
aeee782512 | ||
|
|
fe59a13218 | ||
|
|
c2688517ba | ||
|
|
95019f9eb6 | ||
|
|
f43769bde7 | ||
|
|
c576b62d51 | ||
|
|
722c4466bf | ||
|
|
61b863ae96 | ||
|
|
55ea0d7b2b | ||
|
|
04f56c6d84 | ||
|
|
e7aae4e72a | ||
|
|
3547e7afb8 | ||
|
|
a07e5ab278 | ||
|
|
1ddc32cbd4 | ||
|
|
80a30d059f | ||
|
|
437e6809bf | ||
|
|
b9d4c070eb | ||
|
|
4ef6908e82 | ||
|
|
b854ca8807 | ||
|
|
db89bdfdff | ||
|
|
dc1df527b2 | ||
|
|
584e93fbbf | ||
|
|
c2d4258afc | ||
|
|
60dca5f8c3 | ||
|
|
5d1afab071 | ||
|
|
851e417370 | ||
|
|
71a82ae187 | ||
|
|
0e54e4778e | ||
|
|
053ce880e4 | ||
|
|
67b1e4e862 | ||
|
|
e04a877310 | ||
|
|
48a605eeb0 | ||
|
|
aed08f18bb | ||
|
|
0c626cd2a3 | ||
|
|
82e6aa335b | ||
|
|
f79575e8d5 | ||
|
|
ac0dc0a94a | ||
|
|
7de4ac2b89 | ||
|
|
e01ddc0db7 | ||
|
|
5745eca683 | ||
|
|
8a8ee46234 | ||
|
|
26489627f2 | ||
|
|
341ced2d83 | ||
|
|
c5dd0eb375 | ||
|
|
33045ae36f | ||
|
|
442ebe5919 | ||
|
|
363bcbad18 | ||
|
|
5721cf71d3 | ||
|
|
ed8cc8d01f | ||
|
|
6c38f59e0f | ||
|
|
4d07311afc | ||
|
|
8ec81bb33f | ||
|
|
eb322d0dcd | ||
|
|
42a929d3f1 | ||
|
|
978167ad3f | ||
|
|
9767e1a87d | ||
|
|
4b822d6684 | ||
|
|
b59e41ef62 | ||
|
|
89ddfd3037 | ||
|
|
d97a2bba52 | ||
|
|
e72a8b2b8e | ||
|
|
c3c1d94f92 | ||
|
|
1f8c5a894a | ||
|
|
9dd05fcc70 | ||
|
|
e6487fb199 | ||
|
|
9d9f611091 | ||
|
|
a7c21515cd | ||
|
|
01f1a37bc1 | ||
|
|
5a24f43db3 | ||
|
|
61b2e96bf1 | ||
|
|
affbd0cdb6 | ||
|
|
a2c40a302b | ||
|
|
6ac6353e6a | ||
|
|
203dc1801a | ||
|
|
dc4fbf61a9 | ||
|
|
1fac005db7 | ||
|
|
e9ee658385 | ||
|
|
0785ba70ce | ||
|
|
c4c6867fef | ||
|
|
447a44208f | ||
|
|
eb9bd2ad5f | ||
|
|
5030d2c4c0 | ||
|
|
6b9fc7dd50 | ||
|
|
c7ca0d9707 | ||
|
|
e9a38d0d03 | ||
|
|
bc6ce75268 | ||
|
|
e545f19339 | ||
|
|
ac4682d62c | ||
|
|
3afb446564 | ||
|
|
0ed2232ac2 | ||
|
|
8d9129daaf | ||
|
|
f799606688 | ||
|
|
64adc4f58d | ||
|
|
f6aad3355a | ||
|
|
0badf10a8b | ||
|
|
e5118f5266 | ||
|
|
157d5e6c05 | ||
|
|
a02a8ff9db | ||
|
|
b1497f2ace | ||
|
|
099590c419 | ||
|
|
41d7fd1b86 | ||
|
|
d3d7912bb8 | ||
|
|
12f1ffd019 | ||
|
|
19d0fe97a0 | ||
|
|
771954ffb8 | ||
|
|
f4997f5a7f | ||
|
|
ff5a873d3b | ||
|
|
1b5720f2a5 | ||
|
|
a52730fff0 | ||
|
|
2dfc9b75a2 | ||
|
|
cc6f004e0e | ||
|
|
fa37c72923 | ||
|
|
ab2235d0ca | ||
|
|
cbf707b403 | ||
|
|
8971c7a6a2 | ||
|
|
1576c9cdde | ||
|
|
a0b8603510 | ||
|
|
5b899b16d0 | ||
|
|
a4b9acd622 | ||
|
|
c458f1eafb | ||
|
|
8f8abcc3f6 | ||
|
|
b4b9f90edc | ||
|
|
7cc777f0a6 | ||
|
|
61c068d4ee | ||
|
|
ff021b56f4 | ||
|
|
94ef64c4b7 | ||
|
|
8ad28fd509 | ||
|
|
7148ebcf34 | ||
|
|
1229e9626e | ||
|
|
4ec9a91644 | ||
|
|
1bbe1204e6 | ||
|
|
3aaddfd513 | ||
|
|
f5514728fe | ||
|
|
4fcb3a969b | ||
|
|
23f3182769 | ||
|
|
beba4f029a | ||
|
|
7cf7a62881 | ||
|
|
c1e84715fb | ||
|
|
a3cc5726ee | ||
|
|
b84e10e69f | ||
|
|
ce3a1969c8 | ||
|
|
8282ca7d60 | ||
|
|
104d8da655 | ||
|
|
52c39ad40c | ||
|
|
842ecaaff6 | ||
|
|
8d325aea0a | ||
|
|
3023c02f12 | ||
|
|
efff034dc6 | ||
|
|
2bb5673446 | ||
|
|
6cb090309a | ||
|
|
0983885fa2 | ||
|
|
8d78b19128 | ||
|
|
4449996a91 | ||
|
|
9cf496b7c4 | ||
|
|
4fb1db47ab | ||
|
|
5d890cb3d0 | ||
|
|
257f583f78 | ||
|
|
d45bab3879 | ||
|
|
c871255eb7 | ||
|
|
1a8045b89f | ||
|
|
f91f55fa66 | ||
|
|
10bd46f077 | ||
|
|
bd4fecc3b6 | ||
|
|
14b89fbee2 | ||
|
|
8291c55fc9 | ||
|
|
d542fa6bb6 | ||
|
|
46ddcb7518 | ||
|
|
cf2d1aa6fb | ||
|
|
ab3dd8aacb | ||
|
|
ae868fa9d1 | ||
|
|
4ecbf5978e | ||
|
|
31586cf48f | ||
|
|
3725a6e58f | ||
|
|
313c2ab2bf | ||
|
|
fe5d37f45e | ||
|
|
92f6221ba0 | ||
|
|
0590a0c56f | ||
|
|
13ffc3a515 | ||
|
|
74b36226f2 | ||
|
|
d501d0304a | ||
|
|
1059933c87 | ||
|
|
5fa58b931e | ||
|
|
ddecc72de7 | ||
|
|
d35a0c5e1e | ||
|
|
340994ce77 | ||
|
|
42b2f21c4d | ||
|
|
e4b9da54dd | ||
|
|
ccc41314ae | ||
|
|
93eb6a19a5 | ||
|
|
e4f2e19d2c | ||
|
|
73a687c9a7 | ||
|
|
32ca3c11fa | ||
|
|
0d648dd188 | ||
|
|
86b7989c89 | ||
|
|
01be6ab596 | ||
|
|
a3d01e8d34 | ||
|
|
808bd47b64 | ||
|
|
f4b506b26b | ||
|
|
1f0d2e2039 | ||
|
|
e3e315e2a6 | ||
|
|
bfc733784f | ||
|
|
3ff25de252 | ||
|
|
3c726c1c56 | ||
|
|
9cb7ff691f | ||
|
|
645ae3124f | ||
|
|
a3d1922913 | ||
|
|
62d2ea8f15 | ||
|
|
823752076b | ||
|
|
3cbd392c72 | ||
|
|
57f62f5860 | ||
|
|
648fab6be5 | ||
|
|
817ae68e67 | ||
|
|
7c4b91ddc4 | ||
|
|
d54e015195 | ||
|
|
e369d1ba9d | ||
|
|
1a4358998b | ||
|
|
c53a833d9d | ||
|
|
afff700ad3 | ||
|
|
5bc00bc7f5 | ||
|
|
e2ace90cdb | ||
|
|
1afbd2b6a8 | ||
|
|
d36c5af0c4 | ||
|
|
705bb2b084 | ||
|
|
a208d13930 | ||
|
|
44d8861b7f | ||
|
|
9821f06ca1 | ||
|
|
92f9f56f59 | ||
|
|
424c4d8827 | ||
|
|
24cf2a2725 | ||
|
|
1a5c3c1f6f | ||
|
|
0b8fbf892a | ||
|
|
a2f9356b8a | ||
|
|
7003463bac | ||
|
|
7a663fa9c1 | ||
|
|
a3345d11e7 | ||
|
|
f1ab65ec32 | ||
|
|
6282d25d3d | ||
|
|
47c3f9ff3b | ||
|
|
5cd2f1b9e6 | ||
|
|
5d9b18ec11 | ||
|
|
5aec1f644d | ||
|
|
aee092f0b3 | ||
|
|
9cc1cdac62 | ||
|
|
1e73739ddb | ||
|
|
d1d7cc9adf | ||
|
|
6a0ad7f79b | ||
|
|
f7c70577ae | ||
|
|
937ed798cf | ||
|
|
8da4f0e180 | ||
|
|
170d12f143 | ||
|
|
0fe3409577 | ||
|
|
36e431a1ca | ||
|
|
f30ebda851 | ||
|
|
0f021a2d6e | ||
|
|
f816c8ca6e | ||
|
|
fe0c4605f7 | ||
|
|
196bbff103 | ||
|
|
80b26e62e9 | ||
|
|
f877637fd2 | ||
|
|
5037b4ef84 | ||
|
|
11b7696d31 | ||
|
|
4ad361dab8 | ||
|
|
1b88857e4d | ||
|
|
7823bff063 | ||
|
|
947de6c7c9 | ||
|
|
f689bf0cf7 | ||
|
|
b3028258ca | ||
|
|
2c8476cabd | ||
|
|
5373e58807 | ||
|
|
4fdb781622 | ||
|
|
0981ba771a | ||
|
|
7cec7f5359 | ||
|
|
8e55739685 | ||
|
|
d4a2d97071 | ||
|
|
d51790811a | ||
|
|
93e8e87b03 | ||
|
|
09590cfab0 | ||
|
|
5d91e20844 | ||
|
|
d918b1e274 | ||
|
|
7fc2d2f36f | ||
|
|
7a01fdd04c | ||
|
|
8724f5b30c | ||
|
|
fcc05e5e5d | ||
|
|
f6284e7107 | ||
|
|
5e9dc87470 | ||
|
|
b2f0da9245 | ||
|
|
b27d6dbe9a | ||
|
|
a7caf9848e | ||
|
|
8d44ad8866 | ||
|
|
e98f5b9d54 | ||
|
|
30d1d47cdc | ||
|
|
8d7f44d2da | ||
|
|
930d4dfd83 | ||
|
|
290cb652ee | ||
|
|
1fa470fd00 | ||
|
|
c835ebff3f | ||
|
|
0e76e69aab | ||
|
|
1857d9f4e9 | ||
|
|
34f13ebd52 | ||
|
|
2c4a71cbaa | ||
|
|
48515a13da | ||
|
|
f42cda1584 | ||
|
|
3fbbb01e27 | ||
|
|
182b8abd7a | ||
|
|
dd0445ee79 | ||
|
|
d10b64c17f | ||
|
|
ea13c7dbd8 | ||
|
|
fc5ad9ff90 | ||
|
|
4cee432a82 | ||
|
|
d7c8b12d66 | ||
|
|
87a05ed28a | ||
|
|
87c242e2bb | ||
|
|
1a7b9c6969 | ||
|
|
2786e1a2d5 | ||
|
|
05c37da667 | ||
|
|
ed9ed8e964 | ||
|
|
cb36772fd9 | ||
|
|
cc8e31995b | ||
|
|
82cd8024e9 | ||
|
|
ffc7222b3f | ||
|
|
08c48e9997 | ||
|
|
32b6db7343 | ||
|
|
4dfe0f0d88 | ||
|
|
0e2224eaf7 | ||
|
|
151777cf61 | ||
|
|
47a22064a5 | ||
|
|
1b8d35d424 | ||
|
|
604efef832 | ||
|
|
6f67bd7542 | ||
|
|
52592ba765 | ||
|
|
9bb97c72a1 | ||
|
|
b44cf370aa | ||
|
|
74900970e1 | ||
|
|
4ee52e149e | ||
|
|
4148f4a4b9 | ||
|
|
a59b6a418d | ||
|
|
d6ae67ba07 | ||
|
|
be455bc897 | ||
|
|
129035bda3 | ||
|
|
d558c2fcc0 | ||
|
|
cb5df0d73f | ||
|
|
19e8e3a618 | ||
|
|
5f0514638a | ||
|
|
28ae785142 | ||
|
|
8c59f97b02 | ||
|
|
8a26587250 | ||
|
|
bb68869fe1 | ||
|
|
e60ca7115a | ||
|
|
ee4a780acf | ||
|
|
8dd6ce2739 | ||
|
|
41ad7e90d3 | ||
|
|
31be55d67a | ||
|
|
a50b83d876 | ||
|
|
68ce789db1 | ||
|
|
4702862af3 | ||
|
|
c403705b48 | ||
|
|
22a5f2d5ee | ||
|
|
0f1d8d2835 | ||
|
|
1a6b1672b3 | ||
|
|
8e6006177b | ||
|
|
4e2f010260 | ||
|
|
8a191f8f04 | ||
|
|
88aca02234 | ||
|
|
9189307d00 | ||
|
|
00fc579824 | ||
|
|
7f8d78e0c9 | ||
|
|
13bd3e918e | ||
|
|
e1c46f0604 | ||
|
|
79f3fe196c | ||
|
|
87a47207ab | ||
|
|
772fd5cc6a | ||
|
|
26a2ffcbb1 | ||
|
|
cfecfce51e | ||
|
|
23c814bf53 | ||
|
|
8ca11b214c | ||
|
|
008f2d705a | ||
|
|
c37f795dac | ||
|
|
5d74bdd3b4 | ||
|
|
382b44accc | ||
|
|
78cd0eff09 | ||
|
|
12c954600f | ||
|
|
a7d4f3b784 | ||
|
|
91f9feba59 | ||
|
|
e03a200c32 | ||
|
|
8713faa487 | ||
|
|
15e99c03a9 | ||
|
|
b3933848e9 | ||
|
|
e39e5bf9c4 | ||
|
|
3a42dce45f | ||
|
|
169539f42f | ||
|
|
6360731f34 | ||
|
|
914dd9670a | ||
|
|
498b9aed26 | ||
|
|
b425f3e779 | ||
|
|
c6a51d4d08 | ||
|
|
b8b601821a | ||
|
|
5e8aa4cec7 | ||
|
|
54bb02937d | ||
|
|
74fe786c00 | ||
|
|
503bff292c | ||
|
|
0aa78c0d7e | ||
|
|
8e1d02f356 | ||
|
|
e8f9d22128 | ||
|
|
fa60ae2947 | ||
|
|
663602282a | ||
|
|
98dbc20cb0 | ||
|
|
cc28293bed | ||
|
|
1e90d5541b | ||
|
|
04c7ca7291 | ||
|
|
8d52cab6d8 | ||
|
|
efa13df106 | ||
|
|
8bc29ac331 | ||
|
|
7991f9ca97 | ||
|
|
eb1eee1681 | ||
|
|
b3f748c000 | ||
|
|
58a9f7b25a | ||
|
|
dddb00d5ef | ||
|
|
c9d878a0b7 | ||
|
|
dcb92ed1af | ||
|
|
749bc4a837 | ||
|
|
94807b7788 | ||
|
|
0fe7c66850 | ||
|
|
20cd8413dc | ||
|
|
30df4ede6c | ||
|
|
4aa6baf569 | ||
|
|
d8a4303c50 | ||
|
|
b355e2ee88 | ||
|
|
55e3b5fb9b | ||
|
|
a59853e37a | ||
|
|
ccc665d218 | ||
|
|
02650f5c2a | ||
|
|
24172a1137 | ||
|
|
034d69d490 | ||
|
|
12fc0542d3 | ||
|
|
dcf80ed396 | ||
|
|
28badb7f6c | ||
|
|
19cc158ef8 | ||
|
|
a2eeae3319 | ||
|
|
c9336a753d | ||
|
|
ea23468ecd | ||
|
|
143643fcd8 | ||
|
|
25eb05d305 | ||
|
|
bf217b3cbf | ||
|
|
9e2b60e15e | ||
|
|
4dba90361c | ||
|
|
8dea483f64 | ||
|
|
dc2e603356 | ||
|
|
14973298a0 | ||
|
|
7efc47724e | ||
|
|
c51218240e | ||
|
|
2762caaa8f | ||
|
|
70d66e5a90 | ||
|
|
fc1d704f6f | ||
|
|
c2c3b0f757 | ||
|
|
8d519dd80f | ||
|
|
3b5a9cd2b4 | ||
|
|
95f4d39893 | ||
|
|
3173e30caf | ||
|
|
0dccc66f54 | ||
|
|
6b3dd23c01 | ||
|
|
1c6a125174 | ||
|
|
f3f269c7fa | ||
|
|
15f37644c0 | ||
|
|
c2079ebca5 | ||
|
|
1146269992 | ||
|
|
099362d198 | ||
|
|
22d203fc60 | ||
|
|
19602144ef | ||
|
|
44bbcd7fe3 | ||
|
|
efe5e07c2c | ||
|
|
4e633ff735 | ||
|
|
fef8333763 | ||
|
|
a741f8451a | ||
|
|
55baf5a3f3 | ||
|
|
6390774d86 | ||
|
|
51a5128e70 | ||
|
|
53d81507e4 | ||
|
|
dcf7236ba2 | ||
|
|
a54744abc6 | ||
|
|
22e2411c77 | ||
|
|
3f66c142b8 | ||
|
|
40f262b0ef | ||
|
|
0f68be9663 | ||
|
|
734846a018 | ||
|
|
e75035b33a | ||
|
|
f675c606a2 | ||
|
|
a5199e2f06 | ||
|
|
1b80e48ed4 | ||
|
|
07e81f21c7 | ||
|
|
0dbd01f6fc | ||
|
|
4b453b58dd | ||
|
|
1575bb5242 | ||
|
|
55137cf899 | ||
|
|
f190ff810e | ||
|
|
47c13b46f7 | ||
|
|
2ad9f38906 | ||
|
|
2783c62ace | ||
|
|
c1a65f8055 | ||
|
|
6e5d8e99ca | ||
|
|
020c3b8bba | ||
|
|
76162a06e3 | ||
|
|
19f398d309 | ||
|
|
25ae23963e | ||
|
|
146ba95af6 | ||
|
|
ee10b013a1 | ||
|
|
8c79df3d35 | ||
|
|
2c2db1ca96 | ||
|
|
f556c0b127 | ||
|
|
66645d93f8 | ||
|
|
f2582bce1d | ||
|
|
3ef7c6adb0 | ||
|
|
62e7e5d8c3 | ||
|
|
30e43d3bfe | ||
|
|
0162eaed97 | ||
|
|
15ca4111c0 | ||
|
|
dc45e0f5df | ||
|
|
09b6a967a1 | ||
|
|
1cff0eeac4 | ||
|
|
44349c4ede | ||
|
|
8e8953b07f | ||
|
|
150e3d554f | ||
|
|
be3b5a1897 | ||
|
|
9be0e8595f | ||
|
|
f38370592e | ||
|
|
6a54d42867 | ||
|
|
49d29ae675 | ||
|
|
27d7a6a8cb | ||
|
|
e8d04644f8 | ||
|
|
26b512d42e | ||
|
|
4fb3173185 | ||
|
|
826587b2c9 | ||
|
|
4efdb1d8d1 | ||
|
|
1b9f886d1b | ||
|
|
3241ae5db5 | ||
|
|
30f1b2c73a | ||
|
|
8d35101e98 | ||
|
|
41cfd99d32 | ||
|
|
c8d04e4eb7 | ||
|
|
956831f9d7 | ||
|
|
d65874080b | ||
|
|
bf35a8ffd7 | ||
|
|
eeb8dd8c5b | ||
|
|
299093f863 | ||
|
|
86dea2953a | ||
|
|
81794e6eb2 | ||
|
|
d43887e288 | ||
|
|
e2cf22e054 | ||
|
|
5a75fe77fd | ||
|
|
8c0617c525 | ||
|
|
38b8966c16 | ||
|
|
59f4ff8a3e | ||
|
|
357263b496 | ||
|
|
4af6fc165b | ||
|
|
a4de58b9b3 | ||
|
|
5696ad7fa2 | ||
|
|
63bfca6d3e | ||
|
|
0fecf996e1 | ||
|
|
3df2682332 | ||
|
|
dd9df6e9dc | ||
|
|
0889c2cc28 | ||
|
|
010b1264ae | ||
|
|
66ff32e14d | ||
|
|
addb642cc9 | ||
|
|
720c389dbd | ||
|
|
2191d9c83b | ||
|
|
0ee1cda0e4 | ||
|
|
90226b7b78 | ||
|
|
6d84294533 | ||
|
|
36bd3cc438 | ||
|
|
e0c983f4eb | ||
|
|
ea5ce23335 | ||
|
|
26a33e5d9d | ||
|
|
9ab7159cb9 | ||
|
|
ad21321a1d | ||
|
|
fe2bb05895 | ||
|
|
e48beae324 | ||
|
|
10109ab2c0 | ||
|
|
df17bb5af8 | ||
|
|
b4592015fb | ||
|
|
3fe9ec6918 | ||
|
|
23ac9df844 | ||
|
|
c480992f63 | ||
|
|
85d397def0 | ||
|
|
7c74c87524 | ||
|
|
f86ee7d5c2 | ||
|
|
6e5519419d | ||
|
|
2c53b63847 | ||
|
|
45b5e48676 |
BIN
.github/assets/vtuber.png
vendored
Normal file
BIN
.github/assets/vtuber.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
16
.github/workflows/trigger-site-deploy.yml
vendored
Normal file
16
.github/workflows/trigger-site-deploy.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name: Trigger Site Update
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger-site:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Send repository_dispatch to site-repo
|
||||||
|
uses: peter-evans/repository-dispatch@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.SITE_REPO_TOKEN }}
|
||||||
|
repository: KotatsuApp/website
|
||||||
|
event-type: app-release
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -26,4 +26,4 @@
|
|||||||
.cxx
|
.cxx
|
||||||
/.idea/deviceManager.xml
|
/.idea/deviceManager.xml
|
||||||
/.kotlin/
|
/.kotlin/
|
||||||
/.idea/AndroidProjectSystem.xml
|
/.idea/AndroidProjectSystem.xml
|
||||||
2
.idea/.gitignore
generated
vendored
2
.idea/.gitignore
generated
vendored
@@ -3,3 +3,5 @@
|
|||||||
/workspace.xml
|
/workspace.xml
|
||||||
/migrations.xml
|
/migrations.xml
|
||||||
/runConfigurations.xml
|
/runConfigurations.xml
|
||||||
|
/appInsightsSettings.xml
|
||||||
|
/kotlinCodeInsightSettings.xml
|
||||||
|
|||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
26
.idea/appInsightsSettings.xml
generated
Normal file
26
.idea/appInsightsSettings.xml
generated
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AppInsightsSettings">
|
||||||
|
<option name="tabSettings">
|
||||||
|
<map>
|
||||||
|
<entry key="Firebase Crashlytics">
|
||||||
|
<value>
|
||||||
|
<InsightsFilterSettings>
|
||||||
|
<option name="connection">
|
||||||
|
<ConnectionSetting>
|
||||||
|
<option name="appId" value="PLACEHOLDER" />
|
||||||
|
<option name="mobileSdkAppId" value="" />
|
||||||
|
<option name="projectId" value="" />
|
||||||
|
<option name="projectNumber" value="" />
|
||||||
|
</ConnectionSetting>
|
||||||
|
</option>
|
||||||
|
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||||
|
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||||
|
<option name="visibilityType" value="ALL" />
|
||||||
|
</InsightsFilterSettings>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -6,14 +6,13 @@
|
|||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="jbr-21" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
<option name="resolveExternalAnnotations" value="false" />
|
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -10,6 +10,6 @@
|
|||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
118
README.md
118
README.md
@@ -1,57 +1,117 @@
|
|||||||
# Kotatsu
|
<div align="center">
|
||||||
|
|
||||||
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
<a href="https://kotatsu.app">
|
||||||
|
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
[](https://github.com/KotatsuApp/kotatsu-parsers)   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
# [Kotatsu](https://kotatsu.app)
|
||||||
|
|
||||||
|
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||||
|
|
||||||
|
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
|
|
||||||
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
|
<div align="left">
|
||||||
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
|
|
||||||
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
||||||
|
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
|
||||||
|
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (Unstable, use at your own risk). Application has a built-in self-updating feature.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
### Main Features
|
### Main Features
|
||||||
|
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
<div align="left">
|
||||||
* Search manga by name, genres, and more filters
|
|
||||||
* Reading history and bookmarks
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1200+ manga sources)
|
||||||
|
* Search manga by name, genres and more filters
|
||||||
* Favorites organized by user-defined categories
|
* Favorites organized by user-defined categories
|
||||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
* Reading history, bookmarks and incognito mode support
|
||||||
* Tablet-optimized Material You UI
|
* Download manga and read it offline. Third-party CBZ archives are also supported
|
||||||
* Standard and Webtoon-optimized customizable reader
|
* Clean and convenient Material You UI, optimized for phones, tablets and desktop
|
||||||
* Notifications about new chapters with updates feed
|
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
|
||||||
|
* Notifications about new chapters with updates feed, manga recommendations (with filters)
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
* Password/fingerprint-protected access to the app
|
* Password / fingerprint-protected access to the app
|
||||||
|
* Automatically sync app data with other devices on the same account
|
||||||
|
* Support for older devices running Android 5.0+
|
||||||
|
|
||||||
### Screenshots
|
</div>
|
||||||
|
|
||||||
|  |  |  |
|
### In-App Screenshots
|
||||||
|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
|  |  |
|
<div align="center">
|
||||||
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
|
<img src="./metadata/en-US/images/phoneScreenshots/1.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/2.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/3.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/4.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/5.png" alt="Mobile view" width="250"/>
|
||||||
|
<img src="./metadata/en-US/images/phoneScreenshots/6.png" alt="Mobile view" width="250"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="./metadata/en-US/images/tenInchScreenshots/1.png" alt="Tablet view" width="400"/>
|
||||||
|
<img src="./metadata/en-US/images/tenInchScreenshots/2.png" alt="Tablet view" width="400"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
### Localization
|
### Localization
|
||||||
|
|
||||||
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
|
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
||||||
|
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
|
||||||
|
</a>
|
||||||
|
|
||||||
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
|
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br>
|
||||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
|
**📌 If you would like to help improve these or add new languages,
|
||||||
|
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
<br>
|
||||||
|
|
||||||
|
<a href="https://github.com/KotatsuApp/Kotatsu">
|
||||||
|
<picture>
|
||||||
|
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||||
|
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu GitHub Repository">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/KotatsuApp/Kotatsu-parsers">
|
||||||
|
<picture>
|
||||||
|
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||||
|
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu-parsers GitHub Repository">
|
||||||
|
</picture>
|
||||||
|
</a><br></br>
|
||||||
|
|
||||||
|
</br>
|
||||||
|
|
||||||
|
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||||
|
|
||||||
|
### Certificate fingerprints
|
||||||
|
|
||||||
|
```plaintext
|
||||||
|
2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE
|
||||||
|
```
|
||||||
|
|
||||||
|
```plaintext
|
||||||
|
67:E1:51:00:BB:80:93:01:78:3E:DC:B6:34:8F:A3:BB:F8:30:34:D9:1E:62:86:8A:91:05:3D:BD:70:DB:3F:18
|
||||||
|
```
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
||||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
<div align="left">
|
||||||
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
|
||||||
install instructions.
|
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
### DMCA disclaimer
|
### DMCA disclaimer
|
||||||
|
|
||||||
The developers of this application do not have any affiliation with the content available in the app.
|
<div align="left">
|
||||||
It collects content from sources that are freely available through any web browser
|
|
||||||
|
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -3,32 +3,41 @@ import java.time.LocalDateTime
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'kotlin-android'
|
id 'kotlin-android'
|
||||||
id 'kotlin-kapt'
|
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
id 'kotlin-parcelize'
|
id 'kotlin-parcelize'
|
||||||
id 'dagger.hilt.android.plugin'
|
id 'dagger.hilt.android.plugin'
|
||||||
|
id 'androidx.room'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
// enable if needed
|
||||||
|
// id 'dev.reformator.stacktracedecoroutinator'
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
buildToolsVersion = '35.0.0'
|
buildToolsVersion = '35.0.0'
|
||||||
namespace = 'org.koitharu.kotatsu'
|
namespace = 'org.koitharu.kotatsu'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 23
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = 695
|
versionCode = 1030
|
||||||
versionName = '7.7.3'
|
versionName = '9.2'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
arg('room.generateKotlin', 'true')
|
arg('room.generateKotlin', 'true')
|
||||||
arg('room.schemaLocation', "$projectDir/schemas")
|
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig true
|
// https://issuetracker.google.com/issues/408030127
|
||||||
|
generateLocaleConfig false
|
||||||
}
|
}
|
||||||
|
def localProperties = new Properties()
|
||||||
|
def localPropertiesFile = rootProject.file('local.properties')
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localProperties.load(new FileInputStream(localPropertiesFile))
|
||||||
|
}
|
||||||
|
resValue 'string', 'tg_backup_bot_token', localProperties.getProperty('tg_backup_bot_token', '')
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
@@ -71,11 +80,20 @@ android {
|
|||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||||
|
'-opt-in=kotlinx.coroutines.InternalForInheritanceCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
||||||
|
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||||
|
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
|
||||||
|
'-Xjspecify-annotations=strict',
|
||||||
|
'-Xannotation-default-target=first-only',
|
||||||
|
'-Xtype-enhancement-improvements-strict-mode'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
room {
|
||||||
|
schemaDirectory "$projectDir/schemas"
|
||||||
|
}
|
||||||
lint {
|
lint {
|
||||||
abortOnError true
|
abortOnError true
|
||||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
||||||
@@ -97,13 +115,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
afterEvaluate {
|
|
||||||
compileDebugKotlin {
|
|
||||||
kotlinOptions {
|
|
||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dependencies {
|
dependencies {
|
||||||
def parsersVersion = libs.versions.parsers.get()
|
def parsersVersion = libs.versions.parsers.get()
|
||||||
if (System.properties.containsKey('parsersVersionOverride')) {
|
if (System.properties.containsKey('parsersVersionOverride')) {
|
||||||
@@ -131,6 +142,7 @@ dependencies {
|
|||||||
implementation libs.lifecycle.service
|
implementation libs.lifecycle.service
|
||||||
implementation libs.lifecycle.process
|
implementation libs.lifecycle.process
|
||||||
implementation libs.androidx.constraintlayout
|
implementation libs.androidx.constraintlayout
|
||||||
|
implementation libs.androidx.documentfile
|
||||||
implementation libs.androidx.swiperefreshlayout
|
implementation libs.androidx.swiperefreshlayout
|
||||||
implementation libs.androidx.recyclerview
|
implementation libs.androidx.recyclerview
|
||||||
implementation libs.androidx.viewpager2
|
implementation libs.androidx.viewpager2
|
||||||
@@ -151,14 +163,15 @@ dependencies {
|
|||||||
implementation libs.okhttp.tls
|
implementation libs.okhttp.tls
|
||||||
implementation libs.okhttp.dnsoverhttps
|
implementation libs.okhttp.dnsoverhttps
|
||||||
implementation libs.okio
|
implementation libs.okio
|
||||||
|
implementation libs.kotlinx.serialization.json
|
||||||
|
|
||||||
implementation libs.adapterdelegates
|
implementation libs.adapterdelegates
|
||||||
implementation libs.adapterdelegates.viewbinding
|
implementation libs.adapterdelegates.viewbinding
|
||||||
|
|
||||||
implementation libs.hilt.android
|
implementation libs.hilt.android
|
||||||
kapt libs.hilt.compiler
|
ksp libs.hilt.compiler
|
||||||
implementation libs.androidx.hilt.work
|
implementation libs.androidx.hilt.work
|
||||||
kapt libs.androidx.hilt.compiler
|
ksp libs.androidx.hilt.compiler
|
||||||
|
|
||||||
implementation libs.coil.core
|
implementation libs.coil.core
|
||||||
implementation libs.coil.network
|
implementation libs.coil.network
|
||||||
@@ -168,6 +181,7 @@ dependencies {
|
|||||||
implementation libs.ssiv
|
implementation libs.ssiv
|
||||||
implementation libs.disk.lru.cache
|
implementation libs.disk.lru.cache
|
||||||
implementation libs.markwon
|
implementation libs.markwon
|
||||||
|
implementation libs.kizzyrpc
|
||||||
|
|
||||||
implementation libs.acra.http
|
implementation libs.acra.http
|
||||||
implementation libs.acra.dialog
|
implementation libs.acra.dialog
|
||||||
@@ -175,6 +189,7 @@ dependencies {
|
|||||||
implementation libs.conscrypt.android
|
implementation libs.conscrypt.android
|
||||||
|
|
||||||
debugImplementation libs.leakcanary.android
|
debugImplementation libs.leakcanary.android
|
||||||
|
nightlyImplementation libs.leakcanary.android
|
||||||
debugImplementation libs.workinspector
|
debugImplementation libs.workinspector
|
||||||
|
|
||||||
testImplementation libs.junit
|
testImplementation libs.junit
|
||||||
@@ -192,5 +207,5 @@ dependencies {
|
|||||||
androidTestImplementation libs.moshi.kotlin
|
androidTestImplementation libs.moshi.kotlin
|
||||||
|
|
||||||
androidTestImplementation libs.hilt.android.testing
|
androidTestImplementation libs.hilt.android.testing
|
||||||
kaptAndroidTest libs.hilt.android.compiler
|
kspAndroidTest libs.hilt.android.compiler
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/proguard-rules.pro
vendored
9
app/proguard-rules.pro
vendored
@@ -8,8 +8,7 @@
|
|||||||
public static void checkParameterIsNotNull(...);
|
public static void checkParameterIsNotNull(...);
|
||||||
public static void checkNotNullParameter(...);
|
public static void checkNotNullParameter(...);
|
||||||
}
|
}
|
||||||
-keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
|
|
||||||
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
|
||||||
-dontwarn okhttp3.internal.platform.**
|
-dontwarn okhttp3.internal.platform.**
|
||||||
-dontwarn org.conscrypt.**
|
-dontwarn org.conscrypt.**
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
@@ -17,10 +16,12 @@
|
|||||||
-dontwarn com.google.j2objc.annotations.**
|
-dontwarn com.google.j2objc.annotations.**
|
||||||
-dontwarn coil3.PlatformContext
|
-dontwarn coil3.PlatformContext
|
||||||
|
|
||||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||||
|
-keep class org.koitharu.kotatsu.settings.about.changelog.ChangelogFragment
|
||||||
|
|
||||||
|
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
|
||||||
-keep class org.jsoup.parser.Tag
|
-keep class org.jsoup.parser.Tag
|
||||||
-keep class org.jsoup.internal.StringUtil
|
-keep class org.jsoup.internal.StringUtil
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,13 +30,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 1552943969433540704,
|
"id": 1552943969433540704,
|
||||||
"name": "1 - 1",
|
"title": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -43,8 +46,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540705,
|
"id": 1552943969433540705,
|
||||||
"name": "1 - 2",
|
"title": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -52,8 +56,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540706,
|
"id": 1552943969433540706,
|
||||||
"name": "1 - 3",
|
"title": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -61,8 +66,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540707,
|
"id": 1552943969433540707,
|
||||||
"name": "1 - 4",
|
"title": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -70,8 +76,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540708,
|
"id": 1552943969433540708,
|
||||||
"name": "1 - 5",
|
"title": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -79,8 +86,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541665,
|
"id": 1552943969433541665,
|
||||||
"name": "2 - 1",
|
"title": "2 - 1",
|
||||||
"number": 6,
|
"number": 6,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/1",
|
"url": "/stranstviia_emanon/vol2/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1415570400000,
|
"uploadDate": 1415570400000,
|
||||||
@@ -88,8 +96,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541666,
|
"id": 1552943969433541666,
|
||||||
"name": "2 - 2",
|
"title": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -97,8 +106,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541667,
|
"id": 1552943969433541667,
|
||||||
"name": "2 - 3",
|
"title": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -106,8 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541668,
|
"id": 1552943969433541668,
|
||||||
"name": "2 - 4",
|
"title": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -115,8 +126,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541669,
|
"id": 1552943969433541669,
|
||||||
"name": "2 - 5",
|
"title": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -124,8 +136,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541670,
|
"id": 1552943969433541670,
|
||||||
"name": "2 - 6",
|
"title": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -133,8 +146,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433542626,
|
"id": 1552943969433542626,
|
||||||
"name": "3 - 1",
|
"title": "3 - 1",
|
||||||
"number": 12,
|
"number": 12,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/1",
|
"url": "/stranstviia_emanon/vol3/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -142,8 +156,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433542627,
|
"id": 1552943969433542627,
|
||||||
"name": "3 - 2",
|
"title": "3 - 2",
|
||||||
"number": 13,
|
"number": 13,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/2",
|
"url": "/stranstviia_emanon/vol3/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -151,8 +166,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433542628,
|
"id": 1552943969433542628,
|
||||||
"name": "3 - 3",
|
"title": "3 - 3",
|
||||||
"number": 14,
|
"number": 14,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/3",
|
"url": "/stranstviia_emanon/vol3/3",
|
||||||
"scanlator": "",
|
"scanlator": "",
|
||||||
"uploadDate": 1465851600000,
|
"uploadDate": 1465851600000,
|
||||||
@@ -160,4 +176,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,8 +30,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [],
|
"chapters": [],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,13 +30,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 3552943969433540704,
|
"id": 3552943969433540704,
|
||||||
"name": "1 - 1",
|
"title": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -43,8 +46,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540705,
|
"id": 3552943969433540705,
|
||||||
"name": "1 - 2",
|
"title": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -52,8 +56,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540706,
|
"id": 3552943969433540706,
|
||||||
"name": "1 - 3",
|
"title": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -61,8 +66,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540707,
|
"id": 3552943969433540707,
|
||||||
"name": "1 - 4",
|
"title": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -70,8 +76,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540708,
|
"id": 3552943969433540708,
|
||||||
"name": "1 - 5",
|
"title": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -79,8 +86,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541665,
|
"id": 3552943969433541665,
|
||||||
"name": "2 - 1",
|
"title": "2 - 1",
|
||||||
"number": 6,
|
"number": 6,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/1",
|
"url": "/stranstviia_emanon/vol2/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1415570400000,
|
"uploadDate": 1415570400000,
|
||||||
@@ -88,8 +96,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541666,
|
"id": 3552943969433541666,
|
||||||
"name": "2 - 2",
|
"title": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -97,8 +106,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541667,
|
"id": 3552943969433541667,
|
||||||
"name": "2 - 3",
|
"title": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -106,8 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541668,
|
"id": 3552943969433541668,
|
||||||
"name": "2 - 4",
|
"title": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -115,8 +126,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541669,
|
"id": 3552943969433541669,
|
||||||
"name": "2 - 5",
|
"title": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -124,8 +136,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541670,
|
"id": 3552943969433541670,
|
||||||
"name": "2 - 6",
|
"title": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -133,4 +146,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,13 +30,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 3552943969433540704,
|
"id": 3552943969433540704,
|
||||||
"name": "1 - 1",
|
"title": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -43,8 +46,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540705,
|
"id": 3552943969433540705,
|
||||||
"name": "1 - 2",
|
"title": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -52,8 +56,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540706,
|
"id": 3552943969433540706,
|
||||||
"name": "1 - 3",
|
"title": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -61,8 +66,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540707,
|
"id": 3552943969433540707,
|
||||||
"name": "1 - 4",
|
"title": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -70,8 +76,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540708,
|
"id": 3552943969433540708,
|
||||||
"name": "1 - 5",
|
"title": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -79,8 +86,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541665,
|
"id": 3552943969433541665,
|
||||||
"name": "2 - 1",
|
"title": "2 - 1",
|
||||||
"number": 6,
|
"number": 6,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/1",
|
"url": "/stranstviia_emanon/vol2/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1415570400000,
|
"uploadDate": 1415570400000,
|
||||||
@@ -88,8 +96,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541666,
|
"id": 3552943969433541666,
|
||||||
"name": "2 - 2",
|
"title": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -97,8 +106,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541667,
|
"id": 3552943969433541667,
|
||||||
"name": "2 - 3",
|
"title": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -106,8 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541668,
|
"id": 3552943969433541668,
|
||||||
"name": "2 - 4",
|
"title": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -115,8 +126,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541669,
|
"id": 3552943969433541669,
|
||||||
"name": "2 - 5",
|
"title": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -124,8 +136,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541670,
|
"id": 3552943969433541670,
|
||||||
"name": "2 - 6",
|
"title": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -133,8 +146,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542626,
|
"id": 3552943969433542626,
|
||||||
"name": "3 - 1",
|
"title": "3 - 1",
|
||||||
"number": 12,
|
"number": 12,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/1",
|
"url": "/stranstviia_emanon/vol3/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -142,8 +156,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542627,
|
"id": 3552943969433542627,
|
||||||
"name": "3 - 2",
|
"title": "3 - 2",
|
||||||
"number": 13,
|
"number": 13,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/2",
|
"url": "/stranstviia_emanon/vol3/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -151,8 +166,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542628,
|
"id": 3552943969433542628,
|
||||||
"name": "3 - 3",
|
"title": "3 - 3",
|
||||||
"number": 14,
|
"number": 14,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/3",
|
"url": "/stranstviia_emanon/vol3/3",
|
||||||
"scanlator": "",
|
"scanlator": "",
|
||||||
"uploadDate": 1465851600000,
|
"uploadDate": 1465851600000,
|
||||||
@@ -160,4 +176,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,7 +30,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": null,
|
"description": null,
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,13 +30,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 3552943969433540704,
|
"id": 3552943969433540704,
|
||||||
"name": "1 - 1",
|
"title": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -43,8 +46,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540705,
|
"id": 3552943969433540705,
|
||||||
"name": "1 - 2",
|
"title": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -52,8 +56,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540706,
|
"id": 3552943969433540706,
|
||||||
"name": "1 - 3",
|
"title": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -61,8 +66,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540707,
|
"id": 3552943969433540707,
|
||||||
"name": "1 - 4",
|
"title": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -70,8 +76,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540708,
|
"id": 3552943969433540708,
|
||||||
"name": "1 - 5",
|
"title": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -79,8 +86,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541666,
|
"id": 3552943969433541666,
|
||||||
"name": "2 - 2",
|
"title": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -88,8 +96,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541667,
|
"id": 3552943969433541667,
|
||||||
"name": "2 - 3",
|
"title": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -97,8 +106,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541668,
|
"id": 3552943969433541668,
|
||||||
"name": "2 - 4",
|
"title": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -106,8 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541669,
|
"id": 3552943969433541669,
|
||||||
"name": "2 - 5",
|
"title": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -115,8 +126,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541670,
|
"id": 3552943969433541670,
|
||||||
"name": "2 - 6",
|
"title": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -124,8 +136,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542626,
|
"id": 3552943969433542626,
|
||||||
"name": "3 - 1",
|
"title": "3 - 1",
|
||||||
"number": 12,
|
"number": 12,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/1",
|
"url": "/stranstviia_emanon/vol3/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -133,8 +146,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542627,
|
"id": 3552943969433542627,
|
||||||
"name": "3 - 2",
|
"title": "3 - 2",
|
||||||
"number": 13,
|
"number": 13,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/2",
|
"url": "/stranstviia_emanon/vol3/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -142,8 +156,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542628,
|
"id": 3552943969433542628,
|
||||||
"name": "3 - 3",
|
"title": "3 - 3",
|
||||||
"number": 14,
|
"number": 14,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/3",
|
"url": "/stranstviia_emanon/vol3/3",
|
||||||
"scanlator": "",
|
"scanlator": "",
|
||||||
"uploadDate": 1465851600000,
|
"uploadDate": 1465851600000,
|
||||||
@@ -151,4 +166,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
package org.koitharu.kotatsu
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.squareup.moshi.*
|
import com.squareup.moshi.FromJson
|
||||||
|
import com.squareup.moshi.JsonAdapter
|
||||||
|
import com.squareup.moshi.JsonReader
|
||||||
|
import com.squareup.moshi.JsonWriter
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.ToJson
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.source
|
import okio.source
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import java.util.*
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Date
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
object SampleData {
|
object SampleData {
|
||||||
|
|
||||||
private val moshi = Moshi.Builder()
|
private val moshi = Moshi.Builder()
|
||||||
.add(DateAdapter())
|
.add(DateAdapter())
|
||||||
|
.add(InstantAdapter())
|
||||||
|
.add(MangaSourceAdapter())
|
||||||
.add(KotlinJsonAdapterFactory())
|
.add(KotlinJsonAdapterFactory())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -51,4 +61,36 @@ object SampleData {
|
|||||||
writer.value(value?.time ?: 0L)
|
writer.value(value?.time ?: 0L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private class MangaSourceAdapter : JsonAdapter<MangaSource>() {
|
||||||
|
|
||||||
|
@FromJson
|
||||||
|
override fun fromJson(reader: JsonReader): MangaSource? {
|
||||||
|
val name = reader.nextString() ?: return null
|
||||||
|
return MangaSource(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToJson
|
||||||
|
override fun toJson(writer: JsonWriter, value: MangaSource?) {
|
||||||
|
writer.value(value?.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InstantAdapter : JsonAdapter<Instant>() {
|
||||||
|
|
||||||
|
@FromJson
|
||||||
|
override fun fromJson(reader: JsonReader): Instant? {
|
||||||
|
val ms = reader.nextLong()
|
||||||
|
return if (ms == 0L) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Instant.ofEpochMilli(ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToJson
|
||||||
|
override fun toJson(writer: JsonWriter, value: Instant?) {
|
||||||
|
writer.value(value?.toEpochMilli() ?: 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import org.junit.Rule
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.koitharu.kotatsu.SampleData
|
import org.koitharu.kotatsu.SampleData
|
||||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.domain.AppBackupAgent
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
package org.koitharu.kotatsu
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
|
import androidx.core.content.edit
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
|
import leakcanary.LeakCanary
|
||||||
import org.koitharu.kotatsu.core.BaseApp
|
import org.koitharu.kotatsu.core.BaseApp
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
|
||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
class KotatsuApp : BaseApp() {
|
||||||
|
|
||||||
|
var isLeakCanaryEnabled: Boolean
|
||||||
|
get() = getDebugPreferences(this).getBoolean(KEY_LEAK_CANARY, true)
|
||||||
|
set(value) {
|
||||||
|
getDebugPreferences(this).edit { putBoolean(KEY_LEAK_CANARY, value) }
|
||||||
|
configureLeakCanary()
|
||||||
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
override fun attachBaseContext(base: Context) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
enableStrictMode()
|
enableStrictMode()
|
||||||
|
configureLeakCanary()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureLeakCanary() {
|
||||||
|
LeakCanary.config = LeakCanary.config.copy(
|
||||||
|
dumpHeap = isLeakCanaryEnabled,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableStrictMode() {
|
private fun enableStrictMode() {
|
||||||
@@ -29,8 +41,8 @@ class KotatsuApp : BaseApp() {
|
|||||||
detectNetwork()
|
detectNetwork()
|
||||||
detectDiskWrites()
|
detectDiskWrites()
|
||||||
detectCustomSlowCalls()
|
detectCustomSlowCalls()
|
||||||
|
detectResourceMismatches()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
||||||
penaltyLog()
|
penaltyLog()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
@@ -44,18 +56,15 @@ class KotatsuApp : BaseApp() {
|
|||||||
detectLeakedSqlLiteObjects()
|
detectLeakedSqlLiteObjects()
|
||||||
detectLeakedClosableObjects()
|
detectLeakedClosableObjects()
|
||||||
detectLeakedRegistrationObjects()
|
detectLeakedRegistrationObjects()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
detectContentUriWithoutPermission()
|
||||||
|
}
|
||||||
detectFileUriExposure()
|
detectFileUriExposure()
|
||||||
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
|
||||||
setClassInstanceLimit(PagesCache::class.java, 1)
|
|
||||||
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
|
||||||
setClassInstanceLimit(PageLoader::class.java, 1)
|
|
||||||
setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
|
||||||
penaltyLog()
|
penaltyLog()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
penaltyListener(notifier.executor, notifier)
|
penaltyListener(notifier.executor, notifier)
|
||||||
}
|
}
|
||||||
}.build()
|
}.build(),
|
||||||
)
|
)
|
||||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
||||||
detectWrongFragmentContainer()
|
detectWrongFragmentContainer()
|
||||||
@@ -70,4 +79,13 @@ class KotatsuApp : BaseApp() {
|
|||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val PREFS_DEBUG = "_debug"
|
||||||
|
const val KEY_LEAK_CANARY = "leak_canary"
|
||||||
|
|
||||||
|
fun getDebugPreferences(context: Context): SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFS_DEBUG, MODE_PRIVATE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class StrictModeNotifier(
|
|||||||
.setContentIntent(
|
.setContentIntent(
|
||||||
PendingIntentCompat.getActivity(
|
PendingIntentCompat.getActivity(
|
||||||
context,
|
context,
|
||||||
0,
|
violation.hashCode(),
|
||||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
|
import org.koitharu.kotatsu.core.model.TestMangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import java.util.EnumSet
|
||||||
|
|
||||||
|
/*
|
||||||
|
This class is for parser development and testing purposes
|
||||||
|
You can open it in the app via Settings -> Debug
|
||||||
|
*/
|
||||||
|
class TestMangaRepository(
|
||||||
|
@Suppress("unused") private val loaderContext: MangaLoaderContext,
|
||||||
|
cache: MemoryContentCache
|
||||||
|
) : CachingMangaRepository(cache) {
|
||||||
|
|
||||||
|
override val source = TestMangaSource
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
|
||||||
|
|
||||||
|
override var defaultSortOrder: SortOrder
|
||||||
|
get() = sortOrders.first()
|
||||||
|
set(value) = Unit
|
||||||
|
|
||||||
|
override val filterCapabilities = MangaListFilterCapabilities()
|
||||||
|
|
||||||
|
override suspend fun getFilterOptions() = MangaListFilterOptions()
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
order: SortOrder?,
|
||||||
|
filter: MangaListFilter?
|
||||||
|
): List<Manga> = TODO("Get manga list by filter")
|
||||||
|
|
||||||
|
override suspend fun getDetailsImpl(
|
||||||
|
manga: Manga
|
||||||
|
): Manga = TODO("Fetch manga details")
|
||||||
|
|
||||||
|
override suspend fun getPagesImpl(
|
||||||
|
chapter: MangaChapter
|
||||||
|
): List<MangaPage> = TODO("Get pages for specific chapter")
|
||||||
|
|
||||||
|
override suspend fun getPageUrl(
|
||||||
|
page: MangaPage
|
||||||
|
): String = TODO("Return direct url of page image or page.url if it is already a direct url")
|
||||||
|
|
||||||
|
override suspend fun getRelatedMangaImpl(
|
||||||
|
seed: Manga
|
||||||
|
): List<Manga> = TODO("Get list of related manga. This method is optional and parser library has a default implementation")
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.LifecycleService
|
||||||
|
import leakcanary.AppWatcher
|
||||||
|
|
||||||
|
abstract class BaseService : LifecycleService() {
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context) {
|
||||||
|
super.attachBaseContext(ContextCompat.getContextForLanguage(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
AppWatcher.objectWatcher.watch(
|
||||||
|
watchedObject = this,
|
||||||
|
description = "${javaClass.simpleName} service received Service#onDestroy() callback",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import leakcanary.LeakCanary
|
||||||
|
import org.koitharu.kotatsu.KotatsuApp
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.TestMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
|
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||||
|
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||||
|
import org.koitharu.workinspector.WorkInspector
|
||||||
|
|
||||||
|
class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference.OnPreferenceChangeListener,
|
||||||
|
Preference.OnPreferenceClickListener {
|
||||||
|
|
||||||
|
private val application
|
||||||
|
get() = requireContext().applicationContext as KotatsuApp
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
addPreferencesFromResource(R.xml.pref_debug)
|
||||||
|
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.let { pref ->
|
||||||
|
pref.isChecked = application.isLeakCanaryEnabled
|
||||||
|
pref.onPreferenceChangeListener = this
|
||||||
|
pref.onContainerClickListener = this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.isChecked = application.isLeakCanaryEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
|
||||||
|
KEY_WORK_INSPECTOR -> {
|
||||||
|
startActivity(WorkInspector.getIntent(preference.context))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
KEY_TEST_PARSER -> {
|
||||||
|
router.openList(TestMangaSource, null, null)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceClick(preference: Preference): Boolean = when (preference.key) {
|
||||||
|
KEY_LEAK_CANARY -> {
|
||||||
|
startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean = when (preference.key) {
|
||||||
|
KEY_LEAK_CANARY -> {
|
||||||
|
application.isLeakCanaryEnabled = newValue as Boolean
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val KEY_LEAK_CANARY = "leak_canary"
|
||||||
|
const val KEY_WORK_INSPECTOR = "work_inspector"
|
||||||
|
const val KEY_TEST_PARSER = "test_parser"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.core.view.MenuProvider
|
|
||||||
import leakcanary.LeakCanary
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.workinspector.WorkInspector
|
|
||||||
|
|
||||||
class SettingsMenuProvider(
|
|
||||||
private val context: Context,
|
|
||||||
) : MenuProvider {
|
|
||||||
|
|
||||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
|
||||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
|
||||||
R.id.action_leaks -> {
|
|
||||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_works -> {
|
|
||||||
context.startActivity(WorkInspector.getIntent(context))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
app/src/debug/res/drawable/ic_debug.xml
Normal file
12
app/src/debug/res/drawable/ic_debug.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M20,8H17.19C16.74,7.2 16.12,6.5 15.37,6L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.05,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6C7.87,6.5 7.26,7.21 6.81,8H4V10H6.09C6.03,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.03,15.67 6.09,16H4V18H6.81C8.47,20.87 12.14,21.84 15,20.18C15.91,19.66 16.67,18.9 17.19,18H20V16H17.91C17.97,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.97,10.33 17.91,10H20V8M16,15A4,4 0 0,1 12,19A4,4 0 0,1 8,15V11A4,4 0 0,1 12,7A4,4 0 0,1 16,11V15M14,10V12H10V10H14M10,14H14V16H10V14Z" />
|
||||||
|
</vector>
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<menu
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@id/action_leaks"
|
|
||||||
android:title="@string/leak_canary_display_activity_label"
|
|
||||||
app:showAsAction="never" />
|
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@id/action_works"
|
|
||||||
android:title="@string/wi_lib_name"
|
|
||||||
app:showAsAction="never" />
|
|
||||||
|
|
||||||
</menu>
|
|
||||||
23
app/src/debug/res/xml/pref_debug.xml
Normal file
23
app/src/debug/res/xml/pref_debug.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.preference.PreferenceScreen
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||||
|
android:key="leak_canary"
|
||||||
|
android:persistent="false"
|
||||||
|
android:title="LeakCanary" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:key="work_inspector"
|
||||||
|
android:persistent="false"
|
||||||
|
android:title="@string/wi_lib_name" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:key="test_parser"
|
||||||
|
android:persistent="false"
|
||||||
|
android:title="@string/test_parser"
|
||||||
|
app:allowDividerAbove="true" />
|
||||||
|
|
||||||
|
|
||||||
|
</androidx.preference.PreferenceScreen>
|
||||||
11
app/src/debug/res/xml/pref_root_debug.xml
Normal file
11
app/src/debug/res/xml/pref_root_debug.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.preference.PreferenceScreen
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<PreferenceScreen
|
||||||
|
android:fragment="org.koitharu.kotatsu.settings.DebugSettingsFragment"
|
||||||
|
android:icon="@drawable/ic_debug"
|
||||||
|
android:key="debug"
|
||||||
|
android:title="@string/debug" />
|
||||||
|
|
||||||
|
</androidx.preference.PreferenceScreen>
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission
|
||||||
|
android:name="android.permission.FOREGROUND_SERVICE"
|
||||||
|
tools:ignore="ForegroundServicesPolicy" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
@@ -19,17 +21,19 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission
|
||||||
|
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||||
|
tools:ignore="RequestInstallPackagesPolicy" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="29" />
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
tools:ignore="ScopedStorage" />
|
tools:ignore="AllFilesAccessPolicy,ScopedStorage" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
@@ -44,14 +48,17 @@
|
|||||||
<application
|
<application
|
||||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
|
android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent"
|
||||||
android:dataExtractionRules="@xml/backup_rules"
|
android:dataExtractionRules="@xml/backup_rules"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
|
||||||
|
android:extractNativeLibs="true"
|
||||||
android:fullBackupContent="@xml/backup_content"
|
android:fullBackupContent="@xml/backup_content"
|
||||||
android:fullBackupOnly="true"
|
android:fullBackupOnly="true"
|
||||||
|
android:hasFragileUserData="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
@@ -207,8 +214,12 @@
|
|||||||
android:launchMode="singleTop" />
|
android:launchMode="singleTop" />
|
||||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
|
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
|
||||||
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
|
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.settings.override.OverrideConfigActivity"
|
||||||
|
android:label="@string/edit" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
|
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
|
||||||
|
android:exported="true"
|
||||||
android:label="@string/sync" />
|
android:label="@string/sync" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
|
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
|
||||||
@@ -259,6 +270,27 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
|
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
|
||||||
android:label="@string/tracker_debug_info" />
|
android:label="@string/tracker_debug_info" />
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.picker.ui.PageImagePickActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/pick_manga_page">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.GET_CONTENT" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.OPENABLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PICK" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity"
|
||||||
|
android:label="@string/discord" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
@@ -269,7 +301,7 @@
|
|||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:label="@string/local_manga_processing" />
|
android:label="@string/local_manga_processing" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
|
android:name="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:label="@string/periodic_backups" />
|
android:label="@string/periodic_backups" />
|
||||||
<service
|
<service
|
||||||
@@ -279,6 +311,18 @@
|
|||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
|
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
|
||||||
android:label="@string/local_manga_processing" />
|
android:label="@string/local_manga_processing" />
|
||||||
|
<service
|
||||||
|
android:name="org.koitharu.kotatsu.backups.ui.backup.BackupService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:label="@string/creating_backup" />
|
||||||
|
<service
|
||||||
|
android:name="org.koitharu.kotatsu.backups.ui.restore.RestoreService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:label="@string/restoring_backup" />
|
||||||
|
<service
|
||||||
|
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:label="@string/importing_manga" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||||
android:label="@string/manga_shelf"
|
android:label="@string/manga_shelf"
|
||||||
@@ -324,6 +368,9 @@
|
|||||||
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:label="@string/prefetch_content" />
|
android:label="@string/prefetch_content" />
|
||||||
|
<service
|
||||||
|
android:name="org.koitharu.kotatsu.browser.AdListUpdateService"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
|
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
|
||||||
@@ -361,6 +408,13 @@
|
|||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name="org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler$DiscardReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.koitharu.kotatsu.CAPTCHA_DISCARD" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
<receiver
|
<receiver
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -3,88 +3,76 @@ package org.koitharu.kotatsu.alternatives.domain
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||||
|
import org.koitharu.kotatsu.search.domain.SearchV2Helper
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val MAX_PARALLELISM = 4
|
private const val MAX_PARALLELISM = 4
|
||||||
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
|
|
||||||
|
|
||||||
class AlternativesUseCase @Inject constructor(
|
class AlternativesUseCase @Inject constructor(
|
||||||
private val sourcesRepository: MangaSourcesRepository,
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
|
private val searchHelperFactory: SearchV2Helper.Factory,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
|
suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow<Manga> {
|
||||||
|
val sources = getSources(manga.source, throughDisabledSources)
|
||||||
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
|
|
||||||
val sources = getSources(manga.source)
|
|
||||||
if (sources.isEmpty()) {
|
if (sources.isEmpty()) {
|
||||||
return emptyFlow()
|
return emptyFlow()
|
||||||
}
|
}
|
||||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||||
return channelFlow {
|
return channelFlow {
|
||||||
for (source in sources) {
|
for (source in sources) {
|
||||||
val repository = mangaRepositoryFactory.create(source)
|
|
||||||
if (!repository.filterCapabilities.isSearchSupported) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
launch {
|
launch {
|
||||||
|
val searchHelper = searchHelperFactory.create(source)
|
||||||
val list = runCatchingCancellable {
|
val list = runCatchingCancellable {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
searchHelper(manga.title, SearchKind.TITLE)?.manga
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrNull()
|
||||||
for (item in list) {
|
list?.forEach { m ->
|
||||||
if (item.matches(manga, matchThreshold)) {
|
if (m.id != manga.id) {
|
||||||
send(item)
|
launch {
|
||||||
|
val details = runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(m.source).getDetails(m)
|
||||||
|
}.getOrDefault(m)
|
||||||
|
send(details)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.map {
|
|
||||||
runCatchingCancellable {
|
|
||||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
|
||||||
}.getOrDefault(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
private suspend fun getSources(ref: MangaSource, disabled: Boolean): List<MangaSource> = if (disabled) {
|
||||||
val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
|
sourcesRepository.getDisabledSources()
|
||||||
result.addAll(sourcesRepository.getEnabledSources())
|
} else {
|
||||||
result.sortByDescending { it.priority(ref) }
|
sourcesRepository.getEnabledSources()
|
||||||
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
}.sortedByDescending { it.priority(ref) }
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
|
|
||||||
return matchesTitles(title, ref.title, threshold) ||
|
|
||||||
matchesTitles(title, ref.altTitle, threshold) ||
|
|
||||||
matchesTitles(altTitle, ref.title, threshold) ||
|
|
||||||
matchesTitles(altTitle, ref.altTitle, threshold)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
|
||||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||||
var res = 0
|
var res = 0
|
||||||
if (this is MangaParserSource && ref is MangaParserSource) {
|
if (this is MangaParserSource && ref is MangaParserSource) {
|
||||||
if (locale == ref.locale) res += 2
|
if (locale == ref.locale) {
|
||||||
if (contentType == ref.contentType) res++
|
res += 4
|
||||||
|
} else if (locale.toLocale() == Locale.getDefault()) {
|
||||||
|
res += 2
|
||||||
|
}
|
||||||
|
if (contentType == ref.contentType) {
|
||||||
|
res++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.model.chaptersCount
|
|||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.concat
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -29,12 +30,14 @@ class AutoFixUseCase @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
||||||
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
|
val seed = checkNotNull(
|
||||||
.getDetailsSafe()
|
mangaDataRepository.findMangaById(mangaId, withChapters = true),
|
||||||
|
) { "Manga $mangaId not found" }.getDetailsSafe()
|
||||||
if (seed.isHealthy()) {
|
if (seed.isHealthy()) {
|
||||||
return seed to null // no fix required
|
return seed to null // no fix required
|
||||||
}
|
}
|
||||||
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
|
val replacement = alternativesUseCase(seed, throughDisabledSources = false)
|
||||||
|
.concat(alternativesUseCase(seed, throughDisabledSources = true))
|
||||||
.filter { it.isHealthy() }
|
.filter { it.isHealthy() }
|
||||||
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||||
if (best == null || best < candidate) {
|
if (best == null || best < candidate) {
|
||||||
|
|||||||
@@ -30,21 +30,19 @@ constructor(
|
|||||||
oldManga: Manga,
|
oldManga: Manga,
|
||||||
newManga: Manga,
|
newManga: Manga,
|
||||||
) {
|
) {
|
||||||
val oldDetails =
|
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
if (oldManga.chapters.isNullOrEmpty()) {
|
runCatchingCancellable {
|
||||||
runCatchingCancellable {
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
}.getOrDefault(oldManga)
|
||||||
}.getOrDefault(oldManga)
|
} else {
|
||||||
} else {
|
oldManga
|
||||||
oldManga
|
}
|
||||||
}
|
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||||
val newDetails =
|
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||||
if (newManga.chapters.isNullOrEmpty()) {
|
} else {
|
||||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
newManga
|
||||||
} else {
|
}
|
||||||
newManga
|
mangaDataRepository.storeManga(newDetails, replaceExisting = true)
|
||||||
}
|
|
||||||
mangaDataRepository.storeManga(newDetails)
|
|
||||||
database.withTransaction {
|
database.withTransaction {
|
||||||
// replace favorites
|
// replace favorites
|
||||||
val favoritesDao = database.getFavouritesDao()
|
val favoritesDao = database.getFavouritesDao()
|
||||||
@@ -101,11 +99,11 @@ constructor(
|
|||||||
mangaId = newDetails.id,
|
mangaId = newDetails.id,
|
||||||
rating = prevInfo.rating,
|
rating = prevInfo.rating,
|
||||||
status =
|
status =
|
||||||
prevInfo.status ?: when {
|
prevInfo.status ?: when {
|
||||||
newHistory == null -> ScrobblingStatus.PLANNED
|
newHistory == null -> ScrobblingStatus.PLANNED
|
||||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||||
else -> ScrobblingStatus.READING
|
else -> ScrobblingStatus.READING
|
||||||
},
|
},
|
||||||
comment = prevInfo.comment,
|
comment = prevInfo.comment,
|
||||||
)
|
)
|
||||||
if (newHistory != null) {
|
if (newHistory != null) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.text.style.ForegroundColorSpan
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
@@ -20,15 +21,11 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaExtra
|
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
@@ -51,10 +48,22 @@ fun alternativeAD(
|
|||||||
binding.chipSource.setOnClickListener(clickListener)
|
binding.chipSource.setOnClickListener(clickListener)
|
||||||
|
|
||||||
bind { payloads ->
|
bind { payloads ->
|
||||||
binding.textViewTitle.text = item.manga.title
|
binding.textViewTitle.text = item.mangaModel.title
|
||||||
|
with(binding.iconsView) {
|
||||||
|
clearIcons()
|
||||||
|
if (item.mangaModel.isSaved) addIcon(R.drawable.ic_storage)
|
||||||
|
if (item.mangaModel.isFavorite) addIcon(R.drawable.ic_heart_outline)
|
||||||
|
isVisible = iconsCount > 0
|
||||||
|
}
|
||||||
binding.textViewSubtitle.text = buildSpannedString {
|
binding.textViewSubtitle.text = buildSpannedString {
|
||||||
if (item.chaptersCount > 0) {
|
if (item.chaptersCount > 0) {
|
||||||
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
|
append(
|
||||||
|
context.resources.getQuantityStringSafe(
|
||||||
|
R.plurals.chapters,
|
||||||
|
item.chaptersCount,
|
||||||
|
item.chaptersCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
append(context.getString(R.string.no_chapters))
|
append(context.getString(R.string.no_chapters))
|
||||||
}
|
}
|
||||||
@@ -70,7 +79,10 @@ fun alternativeAD(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
binding.progressView.setProgress(
|
||||||
|
item.mangaModel.progress,
|
||||||
|
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
|
||||||
|
)
|
||||||
binding.chipSource.also { chip ->
|
binding.chipSource.also { chip ->
|
||||||
chip.text = item.manga.source.getTitle(chip.context)
|
chip.text = item.manga.source.getTitle(chip.context)
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
@@ -87,13 +99,6 @@ fun alternativeAD(
|
|||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
}
|
}
|
||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga)
|
||||||
size(CoverSizeResolver(binding.imageViewCover))
|
|
||||||
defaultPlaceholders(context)
|
|
||||||
transformations(TrimTransformation())
|
|
||||||
allowRgb565(true)
|
|
||||||
mangaExtra(item.manga)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,40 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil3.ImageLoader
|
import coil3.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.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||||
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.core.util.ext.systemBarsInsets
|
||||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
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.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
|
||||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||||
|
ListStateHolderListener,
|
||||||
OnListItemClickListener<MangaAlternativeModel> {
|
OnListItemClickListener<MangaAlternativeModel> {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -52,9 +51,10 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
}
|
}
|
||||||
val listAdapter = BaseListAdapter<ListModel>()
|
val listAdapter = BaseListAdapter<ListModel>()
|
||||||
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
||||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null))
|
||||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
|
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
|
||||||
with(viewBinding.recyclerView) {
|
with(viewBinding.recyclerView) {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||||
@@ -62,39 +62,46 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||||
viewModel.content.observe(this, listAdapter)
|
viewModel.list.observe(this, listAdapter)
|
||||||
viewModel.onMigrated.observeEvent(this) {
|
viewModel.onMigrated.observeEvent(this) {
|
||||||
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||||
startActivity(DetailsActivity.newIntent(this, it))
|
router.openDetails(it)
|
||||||
finishAfterTransition()
|
finishAfterTransition()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onApplyWindowInsets(
|
||||||
viewBinding.root.updatePadding(
|
v: View,
|
||||||
left = insets.left,
|
insets: WindowInsetsCompat
|
||||||
right = insets.right,
|
): WindowInsetsCompat {
|
||||||
)
|
val barsInsets = insets.systemBarsInsets
|
||||||
viewBinding.recyclerView.updatePadding(
|
viewBinding.recyclerView.updatePadding(
|
||||||
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
|
left = barsInsets.left,
|
||||||
|
right = barsInsets.right,
|
||||||
|
bottom = barsInsets.bottom,
|
||||||
)
|
)
|
||||||
|
viewBinding.appbar.updatePadding(
|
||||||
|
left = barsInsets.left,
|
||||||
|
right = barsInsets.right,
|
||||||
|
top = barsInsets.top,
|
||||||
|
)
|
||||||
|
return insets.consumeAllSystemBarsInsets()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||||
when (view.id) {
|
when (view.id) {
|
||||||
R.id.chip_source -> startActivity(
|
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
|
||||||
MangaListActivity.newIntent(
|
|
||||||
this,
|
|
||||||
item.manga.source,
|
|
||||||
MangaListFilter(query = viewModel.manga.title),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
R.id.button_migrate -> confirmMigration(item.manga)
|
R.id.button_migrate -> confirmMigration(item.manga)
|
||||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
else -> router.openDetails(item.manga)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRetryClick(error: Throwable) = viewModel.retry()
|
||||||
|
|
||||||
|
override fun onEmptyActionClick() = Unit
|
||||||
|
|
||||||
|
override fun onFooterButtonClick() = viewModel.continueSearch()
|
||||||
|
|
||||||
private fun confirmMigration(target: Manga) {
|
private fun confirmMigration(target: Manga) {
|
||||||
buildAlertDialog(this, isCentered = true) {
|
buildAlertDialog(this, isCentered = true) {
|
||||||
setIcon(R.drawable.ic_replace)
|
setIcon(R.drawable.ic_replace)
|
||||||
@@ -114,10 +121,4 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
}
|
}
|
||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
|
|
||||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,40 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.onEmpty
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.runningFold
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
||||||
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.append
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
import org.koitharu.kotatsu.list.ui.model.ButtonFooter
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -36,46 +43,67 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val alternativesUseCase: AlternativesUseCase,
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
private val migrateUseCase: MigrateUseCase,
|
private val migrateUseCase: MigrateUseCase,
|
||||||
private val historyRepository: HistoryRepository,
|
private val mangaListMapper: MangaListMapper,
|
||||||
private val settings: AppSettings,
|
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
|
||||||
|
|
||||||
|
private var includeDisabledSources = MutableStateFlow(false)
|
||||||
|
private val results = MutableStateFlow<List<MangaAlternativeModel>>(emptyList())
|
||||||
|
|
||||||
|
private var migrationJob: Job? = null
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
|
private val mangaDetails = suspendLazy {
|
||||||
|
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||||
|
}
|
||||||
|
|
||||||
val onMigrated = MutableEventFlow<Manga>()
|
val onMigrated = MutableEventFlow<Manga>()
|
||||||
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
|
||||||
private var migrationJob: Job? = null
|
val list: StateFlow<List<ListModel>> = combine(
|
||||||
|
results,
|
||||||
|
isLoading,
|
||||||
|
includeDisabledSources,
|
||||||
|
) { list, loading, includeDisabled ->
|
||||||
|
when {
|
||||||
|
list.isEmpty() -> listOf(
|
||||||
|
when {
|
||||||
|
loading -> LoadingState
|
||||||
|
else -> EmptyState(
|
||||||
|
icon = R.drawable.ic_empty_common,
|
||||||
|
textPrimary = R.string.nothing_found,
|
||||||
|
textSecondary = R.string.text_search_holder_secondary,
|
||||||
|
actionStringRes = 0,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
loading -> list + LoadingFooter()
|
||||||
|
includeDisabled -> list
|
||||||
|
else -> list + ButtonFooter(R.string.search_disabled_sources)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||||
|
|
||||||
init {
|
init {
|
||||||
launchJob(Dispatchers.Default) {
|
doSearch(throughDisabledSources = false)
|
||||||
val ref = runCatchingCancellable {
|
}
|
||||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
|
||||||
}.getOrDefault(manga)
|
fun retry() {
|
||||||
val refCount = ref.chaptersCount()
|
searchJob?.cancel()
|
||||||
alternativesUseCase(ref)
|
results.value = emptyList()
|
||||||
.map {
|
includeDisabledSources.value = false
|
||||||
MangaAlternativeModel(
|
doSearch(throughDisabledSources = false)
|
||||||
manga = it,
|
}
|
||||||
progress = getProgress(it.id),
|
|
||||||
referenceChapters = refCount,
|
fun continueSearch() {
|
||||||
)
|
if (includeDisabledSources.value) {
|
||||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
return
|
||||||
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
|
}
|
||||||
}.onEmpty {
|
val prevJob = searchJob
|
||||||
emit(
|
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
listOf(
|
includeDisabledSources.value = true
|
||||||
EmptyState(
|
prevJob?.join()
|
||||||
icon = R.drawable.ic_empty_common,
|
doSearch(throughDisabledSources = true)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +117,20 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
|
private fun doSearch(throughDisabledSources: Boolean) {
|
||||||
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
|
val prevJob = searchJob
|
||||||
|
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
prevJob?.cancelAndJoin()
|
||||||
|
val ref = mangaDetails.getOrDefault(manga)
|
||||||
|
val refCount = ref.chaptersCount()
|
||||||
|
alternativesUseCase.invoke(ref, throughDisabledSources)
|
||||||
|
.collect {
|
||||||
|
val model = MangaAlternativeModel(
|
||||||
|
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
|
||||||
|
referenceChapters = refCount,
|
||||||
|
)
|
||||||
|
results.append(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
import androidx.core.app.ServiceCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
@@ -18,19 +17,23 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||||
|
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase.NoAlternativesException
|
||||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AutoFixService : CoroutineIntentService() {
|
class AutoFixService : CoroutineIntentService() {
|
||||||
@@ -45,33 +48,35 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||||
startForeground(this)
|
startForeground(this)
|
||||||
for (mangaId in ids) {
|
for (mangaId in ids) {
|
||||||
val result = runCatchingCancellable {
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
autoFixUseCase.invoke(mangaId)
|
val result = runCatchingCancellable {
|
||||||
}
|
autoFixUseCase.invoke(mangaId)
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
}
|
||||||
val notification = buildNotification(result)
|
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
notificationManager.notify(TAG, startId, notification)
|
val notification = buildNotification(startId, result)
|
||||||
|
notificationManager.notify(TAG, startId, notification)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) {
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
|
||||||
notificationManager.notify(TAG, startId, notification)
|
notificationManager.notify(TAG, startId, notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
private fun startForeground(jobContext: IntentJobContext) {
|
private fun startForeground(jobContext: IntentJobContext) {
|
||||||
val title = applicationContext.getString(R.string.fixing_manga)
|
val title = getString(R.string.fixing_manga)
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||||
.setName(title)
|
.setName(title)
|
||||||
.setShowBadge(false)
|
.setShowBadge(false)
|
||||||
@@ -81,7 +86,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
.build()
|
.build()
|
||||||
notificationManager.createNotificationChannel(channel)
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
.setDefaults(0)
|
.setDefaults(0)
|
||||||
@@ -92,8 +97,8 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
.addAction(
|
.addAction(
|
||||||
materialR.drawable.material_ic_clear_black_24dp,
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
applicationContext.getString(android.R.string.cancel),
|
getString(android.R.string.cancel),
|
||||||
jobContext.getCancelIntent(),
|
jobContext.getCancelIntent(),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
@@ -105,8 +110,8 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
|
private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setDefaults(0)
|
.setDefaults(0)
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
@@ -115,59 +120,64 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
if (replacement != null) {
|
if (replacement != null) {
|
||||||
notification.setLargeIcon(
|
notification.setLargeIcon(
|
||||||
coil.execute(
|
coil.execute(
|
||||||
ImageRequest.Builder(applicationContext)
|
ImageRequest.Builder(this)
|
||||||
.data(replacement.coverUrl)
|
.data(replacement.coverUrl)
|
||||||
.mangaSourceExtra(replacement.source)
|
.mangaSourceExtra(replacement.source)
|
||||||
.build(),
|
.build(),
|
||||||
).toBitmapOrNull(),
|
).toBitmapOrNull(),
|
||||||
)
|
)
|
||||||
notification.setSubText(replacement.title)
|
notification.setSubText(replacement.title)
|
||||||
val intent = DetailsActivity.newIntent(applicationContext, replacement)
|
val intent = AppRouter.detailsIntent(this, replacement)
|
||||||
notification.setContentIntent(
|
notification.setContentIntent(
|
||||||
PendingIntentCompat.getActivity(
|
PendingIntentCompat.getActivity(
|
||||||
applicationContext,
|
this,
|
||||||
replacement.id.toInt(),
|
replacement.id.toInt(),
|
||||||
intent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
).setVisibility(
|
).setVisibility(
|
||||||
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
|
if (replacement.isNsfw()) {
|
||||||
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
|
} else {
|
||||||
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
|
},
|
||||||
)
|
)
|
||||||
notification
|
notification
|
||||||
.setContentTitle(applicationContext.getString(R.string.fixed))
|
.setContentTitle(getString(R.string.fixed))
|
||||||
.setContentText(
|
.setContentText(
|
||||||
applicationContext.getString(
|
getString(
|
||||||
R.string.manga_replaced,
|
R.string.manga_replaced,
|
||||||
seed.title,
|
seed.title,
|
||||||
seed.source.getTitle(applicationContext),
|
seed.source.getTitle(this),
|
||||||
replacement.title,
|
replacement.title,
|
||||||
replacement.source.getTitle(applicationContext),
|
replacement.source.getTitle(this),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.setSmallIcon(R.drawable.ic_stat_done)
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
} else {
|
} else {
|
||||||
notification
|
notification
|
||||||
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
.setContentTitle(getString(R.string.fixing_manga))
|
||||||
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
.setContentText(getString(R.string.no_fix_required, seed.title))
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
notification
|
notification
|
||||||
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
.setContentTitle(getString(R.string.error_occurred))
|
||||||
.setContentText(
|
.setContentText(
|
||||||
if (error is AutoFixUseCase.NoAlternativesException) {
|
if (error is NoAlternativesException) {
|
||||||
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||||
} else {
|
} else {
|
||||||
error.getDisplayMessage(applicationContext.resources)
|
error.getDisplayMessage(resources)
|
||||||
},
|
},
|
||||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
ErrorReporterReceiver.getNotificationAction(
|
||||||
notification.addAction(
|
context = this,
|
||||||
R.drawable.ic_alert_outline,
|
e = error,
|
||||||
applicationContext.getString(R.string.report),
|
notificationId = startId,
|
||||||
reportIntent,
|
notificationTag = TAG,
|
||||||
)
|
)?.let { action ->
|
||||||
|
notification.addAction(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return notification.build()
|
return notification.build()
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaAlternativeModel(
|
data class MangaAlternativeModel(
|
||||||
val manga: Manga,
|
val mangaModel: MangaGridModel,
|
||||||
val progress: ReadingProgress?,
|
|
||||||
private val referenceChapters: Int,
|
private val referenceChapters: Int,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
|
val manga: Manga
|
||||||
|
get() = mangaModel.manga
|
||||||
|
|
||||||
val chaptersCount = manga.chaptersCount()
|
val chaptersCount = manga.chaptersCount()
|
||||||
|
|
||||||
val chaptersDiff: Int
|
val chaptersDiff: Int
|
||||||
@@ -19,4 +21,10 @@ data class MangaAlternativeModel(
|
|||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
return other is MangaAlternativeModel && other.manga.id == manga.id
|
return other is MangaAlternativeModel && other.manga.id == manga.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(previousState: ListModel): Any? = if (previousState is MangaAlternativeModel) {
|
||||||
|
mangaModel.getChangePayload(previousState.mangaModel)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data
|
||||||
|
|
||||||
|
import androidx.collection.ArrayMap
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import dagger.Reusable
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlinx.coroutines.flow.collectIndexed
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
|
import kotlinx.serialization.SerializationStrategy
|
||||||
|
import kotlinx.serialization.json.DecodeSequenceMode
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeToSequence
|
||||||
|
import kotlinx.serialization.json.encodeToStream
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BackupIndex
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BookmarkBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.CategoryBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.MangaBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.SourceBackup
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@Reusable
|
||||||
|
class BackupRepository @Inject constructor(
|
||||||
|
private val database: MangaDatabase,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val tapGridSettings: TapGridSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
allowSpecialFloatingPointValues = true
|
||||||
|
coerceInputValues = true
|
||||||
|
encodeDefaults = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
useAlternativeNames = false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createBackup(
|
||||||
|
output: ZipOutputStream,
|
||||||
|
progress: FlowCollector<Progress>?,
|
||||||
|
) {
|
||||||
|
progress?.emit(Progress.INDETERMINATE)
|
||||||
|
var commonProgress = Progress(0, BackupSection.entries.size)
|
||||||
|
for (section in BackupSection.entries) {
|
||||||
|
when (section) {
|
||||||
|
BackupSection.INDEX -> output.writeJsonArray(
|
||||||
|
section = BackupSection.INDEX,
|
||||||
|
data = flowOf(BackupIndex()),
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.HISTORY -> output.writeJsonArray(
|
||||||
|
section = BackupSection.HISTORY,
|
||||||
|
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.CATEGORIES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.CATEGORIES,
|
||||||
|
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.FAVOURITES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.FAVOURITES,
|
||||||
|
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SETTINGS -> output.writeString(
|
||||||
|
section = BackupSection.SETTINGS,
|
||||||
|
data = dumpSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
||||||
|
section = BackupSection.SETTINGS_READER_GRID,
|
||||||
|
data = dumpReaderGridSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
||||||
|
section = BackupSection.BOOKMARKS,
|
||||||
|
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SOURCES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.SOURCES,
|
||||||
|
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
commonProgress++
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreBackup(
|
||||||
|
input: ZipInputStream,
|
||||||
|
sections: Set<BackupSection>,
|
||||||
|
progress: FlowCollector<Progress>?,
|
||||||
|
): CompositeResult {
|
||||||
|
progress?.emit(Progress.INDETERMINATE)
|
||||||
|
var commonProgress = Progress(0, sections.size)
|
||||||
|
var entry = input.nextEntry
|
||||||
|
var result = CompositeResult.EMPTY
|
||||||
|
while (entry != null) {
|
||||||
|
val section = BackupSection.of(entry)
|
||||||
|
if (section in sections) {
|
||||||
|
result = result + when (section) {
|
||||||
|
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
|
||||||
|
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getHistoryDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
||||||
|
getFavouriteCategoriesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getFavouritesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SETTINGS -> input.readMap().let {
|
||||||
|
settings.upsertAll(it)
|
||||||
|
CompositeResult.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
||||||
|
tapGridSettings.upsertAll(it)
|
||||||
|
CompositeResult.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
||||||
|
getSourcesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> CompositeResult.EMPTY // skip unknown entries
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
commonProgress++
|
||||||
|
}
|
||||||
|
input.closeEntry()
|
||||||
|
entry = input.nextEntry
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
||||||
|
section: BackupSection,
|
||||||
|
data: Flow<T>,
|
||||||
|
serializer: SerializationStrategy<T>,
|
||||||
|
) {
|
||||||
|
data.onStart {
|
||||||
|
putNextEntry(ZipEntry(section.entryName))
|
||||||
|
write("[")
|
||||||
|
}.onCompletion { error ->
|
||||||
|
if (error == null) {
|
||||||
|
write("]")
|
||||||
|
}
|
||||||
|
closeEntry()
|
||||||
|
flush()
|
||||||
|
}.collectIndexed { index, value ->
|
||||||
|
if (index > 0) {
|
||||||
|
write(",")
|
||||||
|
}
|
||||||
|
json.encodeToStream(serializer, value, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> InputStream.readJsonArray(
|
||||||
|
serializer: DeserializationStrategy<T>,
|
||||||
|
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
||||||
|
|
||||||
|
private fun InputStream.readMap(): Map<String, Any?> {
|
||||||
|
val jo = JSONArray(readString()).getJSONObject(0)
|
||||||
|
val map = ArrayMap<String, Any?>(jo.length())
|
||||||
|
val keys = jo.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val key = keys.next()
|
||||||
|
map[key] = jo.get(key)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipOutputStream.writeString(
|
||||||
|
section: BackupSection,
|
||||||
|
data: String,
|
||||||
|
) {
|
||||||
|
putNextEntry(ZipEntry(section.entryName))
|
||||||
|
try {
|
||||||
|
write("[")
|
||||||
|
write(data)
|
||||||
|
write("]")
|
||||||
|
} finally {
|
||||||
|
closeEntry()
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun OutputStream.write(str: String) = write(str.toByteArray())
|
||||||
|
|
||||||
|
private fun InputStream.readString(): String = readBytes().decodeToString()
|
||||||
|
|
||||||
|
private fun dumpSettings(): String {
|
||||||
|
val map = settings.getAllValues().toMutableMap()
|
||||||
|
map.remove(AppSettings.KEY_APP_PASSWORD)
|
||||||
|
map.remove(AppSettings.KEY_PROXY_PASSWORD)
|
||||||
|
map.remove(AppSettings.KEY_PROXY_LOGIN)
|
||||||
|
map.remove(AppSettings.KEY_INCOGNITO_MODE)
|
||||||
|
return JSONObject(map).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dumpReaderGridSettings(): String {
|
||||||
|
return JSONObject(tapGridSettings.getAllValues()).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
|
||||||
|
val tags = manga.tags.map { it.toEntity() }
|
||||||
|
getTagsDao().upsert(tags)
|
||||||
|
getMangaDao().upsert(manga.toEntity(), tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
|
||||||
|
return fold(CompositeResult.EMPTY) { result, item ->
|
||||||
|
result + runCatchingCancellable {
|
||||||
|
database.withTransaction {
|
||||||
|
database.block(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BackupIndex(
|
||||||
|
@SerialName("app_id") val appId: String,
|
||||||
|
@SerialName("app_version") val appVersion: Int,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor() : this(
|
||||||
|
appId = BuildConfig.APPLICATION_ID,
|
||||||
|
appVersion = BuildConfig.VERSION_CODE,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BookmarkBackup(
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
@SerialName("tags") val tags: Set<TagBackup>,
|
||||||
|
@SerialName("bookmarks") val bookmarks: List<Bookmark>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Bookmark(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("page_id") val pageId: Long,
|
||||||
|
@SerialName("chapter_id") val chapterId: Long,
|
||||||
|
@SerialName("page") val page: Int,
|
||||||
|
@SerialName("scroll") val scroll: Int,
|
||||||
|
@SerialName("image_url") val imageUrl: String,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("percent") val percent: Float,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun toEntity() = BookmarkEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
pageId = pageId,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
createdAt = createdAt,
|
||||||
|
percent = percent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(manga: MangaWithTags, entities: List<BookmarkEntity>) : this(
|
||||||
|
manga = MangaBackup(manga.copy(tags = emptyList())),
|
||||||
|
tags = manga.tags.mapToSet { TagBackup(it) },
|
||||||
|
bookmarks = entities.map {
|
||||||
|
Bookmark(
|
||||||
|
mangaId = it.mangaId,
|
||||||
|
pageId = it.pageId,
|
||||||
|
chapterId = it.chapterId,
|
||||||
|
page = it.page,
|
||||||
|
scroll = it.scroll,
|
||||||
|
imageUrl = it.imageUrl,
|
||||||
|
createdAt = it.createdAt,
|
||||||
|
percent = it.percent,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class CategoryBackup(
|
||||||
|
@SerialName("category_id") val categoryId: Int,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("sort_key") val sortKey: Int,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("order") val order: String = ListSortOrder.NEWEST.name,
|
||||||
|
@SerialName("track") val track: Boolean = true,
|
||||||
|
@SerialName("show_in_lib") val isVisibleInLibrary: Boolean = true,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: FavouriteCategoryEntity) : this(
|
||||||
|
categoryId = entity.categoryId,
|
||||||
|
createdAt = entity.createdAt,
|
||||||
|
sortKey = entity.sortKey,
|
||||||
|
title = entity.title,
|
||||||
|
order = entity.order,
|
||||||
|
track = entity.track,
|
||||||
|
isVisibleInLibrary = entity.isVisibleInLibrary,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = FavouriteCategoryEntity(
|
||||||
|
categoryId = categoryId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
sortKey = sortKey,
|
||||||
|
title = title,
|
||||||
|
order = order,
|
||||||
|
track = track,
|
||||||
|
isVisibleInLibrary = isVisibleInLibrary,
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteManga
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FavouriteBackup(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("category_id") val categoryId: Long,
|
||||||
|
@SerialName("sort_key") val sortKey: Int = 0,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: FavouriteManga) : this(
|
||||||
|
mangaId = entity.manga.id,
|
||||||
|
categoryId = entity.favourite.categoryId,
|
||||||
|
sortKey = entity.favourite.sortKey,
|
||||||
|
isPinned = entity.favourite.isPinned,
|
||||||
|
createdAt = entity.favourite.createdAt,
|
||||||
|
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = FavouriteEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
categoryId = categoryId,
|
||||||
|
sortKey = sortKey,
|
||||||
|
isPinned = isPinned,
|
||||||
|
createdAt = createdAt,
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryWithManga
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class HistoryBackup(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("updated_at") val updatedAt: Long,
|
||||||
|
@SerialName("chapter_id") val chapterId: Long,
|
||||||
|
@SerialName("page") val page: Int,
|
||||||
|
@SerialName("scroll") val scroll: Float,
|
||||||
|
@SerialName("percent") val percent: Float = PROGRESS_NONE,
|
||||||
|
@SerialName("chapters") val chaptersCount: Int = 0,
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: HistoryWithManga) : this(
|
||||||
|
mangaId = entity.manga.id,
|
||||||
|
createdAt = entity.history.createdAt,
|
||||||
|
updatedAt = entity.history.updatedAt,
|
||||||
|
chapterId = entity.history.chapterId,
|
||||||
|
page = entity.history.page,
|
||||||
|
scroll = entity.history.scroll,
|
||||||
|
percent = entity.history.percent,
|
||||||
|
chaptersCount = entity.history.chaptersCount,
|
||||||
|
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = HistoryEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
percent = percent,
|
||||||
|
deletedAt = 0L,
|
||||||
|
chaptersCount = chaptersCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangaBackup(
|
||||||
|
@SerialName("id") val id: Long,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("alt_title") val altTitles: String? = null,
|
||||||
|
@SerialName("url") val url: String,
|
||||||
|
@SerialName("public_url") val publicUrl: String,
|
||||||
|
@SerialName("rating") val rating: Float = RATING_UNKNOWN,
|
||||||
|
@SerialName("nsfw") val isNsfw: Boolean = false,
|
||||||
|
@SerialName("content_rating") val contentRating: String? = null,
|
||||||
|
@SerialName("cover_url") val coverUrl: String,
|
||||||
|
@SerialName("large_cover_url") val largeCoverUrl: String? = null,
|
||||||
|
@SerialName("state") val state: String? = null,
|
||||||
|
@SerialName("author") val authors: String? = null,
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("tags") val tags: Set<TagBackup> = emptySet(),
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: MangaWithTags) : this(
|
||||||
|
id = entity.manga.id,
|
||||||
|
title = entity.manga.title,
|
||||||
|
altTitles = entity.manga.altTitles,
|
||||||
|
url = entity.manga.url,
|
||||||
|
publicUrl = entity.manga.publicUrl,
|
||||||
|
rating = entity.manga.rating,
|
||||||
|
isNsfw = entity.manga.isNsfw,
|
||||||
|
contentRating = entity.manga.contentRating,
|
||||||
|
coverUrl = entity.manga.coverUrl,
|
||||||
|
largeCoverUrl = entity.manga.largeCoverUrl,
|
||||||
|
state = entity.manga.state,
|
||||||
|
authors = entity.manga.authors,
|
||||||
|
source = entity.manga.source,
|
||||||
|
tags = entity.tags.mapToSet { TagBackup(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = MangaEntity(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
altTitles = altTitles,
|
||||||
|
url = url,
|
||||||
|
publicUrl = publicUrl,
|
||||||
|
rating = rating,
|
||||||
|
isNsfw = isNsfw,
|
||||||
|
contentRating = contentRating,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
largeCoverUrl = largeCoverUrl,
|
||||||
|
state = state,
|
||||||
|
authors = authors,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SourceBackup(
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("sort_key") val sortKey: Int,
|
||||||
|
@SerialName("used_at") val lastUsedAt: Long,
|
||||||
|
@SerialName("added_in") val addedIn: Int,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
@SerialName("enabled") val isEnabled: Boolean = true, // for compatibility purposes, should be only true
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: MangaSourceEntity) : this(
|
||||||
|
source = entity.source,
|
||||||
|
sortKey = entity.sortKey,
|
||||||
|
lastUsedAt = entity.lastUsedAt,
|
||||||
|
addedIn = entity.addedIn,
|
||||||
|
isPinned = entity.isPinned,
|
||||||
|
isEnabled = entity.isEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = MangaSourceEntity(
|
||||||
|
source = source,
|
||||||
|
isEnabled = isEnabled,
|
||||||
|
sortKey = sortKey,
|
||||||
|
addedIn = addedIn,
|
||||||
|
lastUsedAt = lastUsedAt,
|
||||||
|
isPinned = isPinned,
|
||||||
|
cfState = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TagBackup(
|
||||||
|
@SerialName("id") val id: Long,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("key") val key: String,
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: TagEntity) : this(
|
||||||
|
id = entity.id,
|
||||||
|
title = entity.title,
|
||||||
|
key = entity.key,
|
||||||
|
source = entity.source,
|
||||||
|
isPinned = entity.isPinned,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = TagEntity(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
key = key,
|
||||||
|
source = source,
|
||||||
|
isPinned = isPinned,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.app.backup.BackupAgent
|
||||||
|
import android.app.backup.BackupDataInput
|
||||||
|
import android.app.backup.BackupDataOutput
|
||||||
|
import android.app.backup.FullBackupDataOutput
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.google.common.io.ByteStreams
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
class AppBackupAgent : BackupAgent() {
|
||||||
|
|
||||||
|
override fun onBackup(
|
||||||
|
oldState: ParcelFileDescriptor?,
|
||||||
|
data: BackupDataOutput?,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onRestore(
|
||||||
|
data: BackupDataInput?,
|
||||||
|
appVersionCode: Int,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||||
|
super.onFullBackup(data)
|
||||||
|
val file =
|
||||||
|
createBackupFile(
|
||||||
|
this,
|
||||||
|
BackupRepository(
|
||||||
|
MangaDatabase(context = applicationContext),
|
||||||
|
AppSettings(applicationContext),
|
||||||
|
TapGridSettings(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fullBackupFile(file, data)
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreFile(
|
||||||
|
data: ParcelFileDescriptor,
|
||||||
|
size: Long,
|
||||||
|
destination: File?,
|
||||||
|
type: Int,
|
||||||
|
mode: Long,
|
||||||
|
mtime: Long
|
||||||
|
) {
|
||||||
|
if (destination?.name?.endsWith(".bk.zip") == true) {
|
||||||
|
restoreBackupFile(
|
||||||
|
data.fileDescriptor,
|
||||||
|
size,
|
||||||
|
BackupRepository(
|
||||||
|
database = MangaDatabase(applicationContext),
|
||||||
|
settings = AppSettings(applicationContext),
|
||||||
|
tapGridSettings = TapGridSettings(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
destination.delete()
|
||||||
|
} else {
|
||||||
|
super.onRestoreFile(data, size, destination, type, mode, mtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun createBackupFile(context: Context, repository: BackupRepository): File {
|
||||||
|
val file = BackupUtils.createTempFile(context)
|
||||||
|
ZipOutputStream(file.outputStream()).use { output ->
|
||||||
|
runBlocking {
|
||||||
|
repository.createBackup(output, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
||||||
|
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
|
||||||
|
runBlocking {
|
||||||
|
repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -6,7 +6,7 @@ import java.util.Date
|
|||||||
data class BackupFile(
|
data class BackupFile(
|
||||||
val uri: Uri,
|
val uri: Uri,
|
||||||
val dateTime: Date,
|
val dateTime: Date,
|
||||||
): Comparable<BackupFile> {
|
) : Comparable<BackupFile> {
|
||||||
|
|
||||||
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
|
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
import android.app.backup.BackupManager
|
import android.app.backup.BackupManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -13,7 +13,13 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class BackupObserver @Inject constructor(
|
class BackupObserver @Inject constructor(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
|
) : InvalidationTracker.Observer(
|
||||||
|
arrayOf(
|
||||||
|
TABLE_HISTORY,
|
||||||
|
TABLE_FAVOURITES,
|
||||||
|
TABLE_FAVOURITE_CATEGORIES,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
|
||||||
private val backupManager = BackupManager(context)
|
private val backupManager = BackupManager(context)
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
|
||||||
|
enum class BackupSection(
|
||||||
|
val entryName: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
INDEX("index"),
|
||||||
|
HISTORY("history"),
|
||||||
|
CATEGORIES("categories"),
|
||||||
|
FAVOURITES("favourites"),
|
||||||
|
SETTINGS("settings"),
|
||||||
|
SETTINGS_READER_GRID("reader_grid"),
|
||||||
|
BOOKMARKS("bookmarks"),
|
||||||
|
SOURCES("sources"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(entry: ZipEntry): BackupSection? {
|
||||||
|
val name = entry.name.lowercase(Locale.ROOT)
|
||||||
|
return entries.first { x -> x.entryName == name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import java.io.File
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object BackupUtils {
|
||||||
|
|
||||||
|
private const val DIR_BACKUPS = "backups"
|
||||||
|
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun createTempFile(context: Context): File {
|
||||||
|
val dir = getAppBackupDir(context)
|
||||||
|
dir.mkdirs()
|
||||||
|
return File(dir, generateFileName(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAppBackupDir(context: Context) = context.run {
|
||||||
|
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBackupDateTime(fileName: String): Date? = try {
|
||||||
|
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateFileName(context: Context) = buildString {
|
||||||
|
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||||
|
append('_')
|
||||||
|
append(dateTimeFormat.format(Date()))
|
||||||
|
append(".bk.zip")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -28,7 +28,7 @@ class ExternalBackupStorage @Inject constructor(
|
|||||||
BackupFile(
|
BackupFile(
|
||||||
uri = it.uri,
|
uri = it.uri,
|
||||||
dateTime = it.name?.let { fileName ->
|
dateTime = it.name?.let { fileName ->
|
||||||
BackupZipOutput.parseBackupDateTime(fileName)
|
BackupUtils.parseBackupDateTime(fileName)
|
||||||
} ?: return@mapNotNull null,
|
} ?: return@mapNotNull null,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -44,7 +44,12 @@ class ExternalBackupStorage @Inject constructor(
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
|
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
|
||||||
val out = checkNotNull(getRootOrThrow().createFile("application/zip", file.nameWithoutExtension)) {
|
val out = checkNotNull(
|
||||||
|
getRootOrThrow().createFile(
|
||||||
|
"application/zip",
|
||||||
|
file.nameWithoutExtension,
|
||||||
|
),
|
||||||
|
) {
|
||||||
"Cannot create target backup file"
|
"Cannot create target backup file"
|
||||||
}
|
}
|
||||||
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
|
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ShareCompat
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getFileDisplayName
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
protected abstract val notificationTag: String
|
||||||
|
protected abstract val isRestoreService: Boolean
|
||||||
|
|
||||||
|
protected lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
|
createNotificationChannel(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
|
showResultNotification(null, CompositeResult.failure(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun IntentJobContext.showResultNotification(
|
||||||
|
fileUri: Uri?,
|
||||||
|
result: CompositeResult,
|
||||||
|
) {
|
||||||
|
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSubText(fileUri?.let { contentResolver.getFileDisplayName(it) })
|
||||||
|
when {
|
||||||
|
result.isAllSuccess -> {
|
||||||
|
if (isRestoreService) {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.restoring_backup))
|
||||||
|
.setContentText(getString(R.string.data_restored_success))
|
||||||
|
} else {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.backup_saved))
|
||||||
|
.setContentText(fileUri?.let { contentResolver.getFileDisplayName(it) })
|
||||||
|
.setSubText(null)
|
||||||
|
|
||||||
|
}
|
||||||
|
notification.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.isAllFailed || !isRestoreService -> {
|
||||||
|
val title = getString(if (isRestoreService) R.string.data_not_restored else R.string.error_occurred)
|
||||||
|
val message = result.failures.joinToString("\n") {
|
||||||
|
it.getDisplayMessage(applicationContext.resources)
|
||||||
|
}
|
||||||
|
notification
|
||||||
|
.setContentText(if (isRestoreService) getString(R.string.data_not_restored_text) else message)
|
||||||
|
.setBigText(title, message)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
result.failures.firstNotNullOfOrNull { error ->
|
||||||
|
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, notificationTag)
|
||||||
|
}?.let { action ->
|
||||||
|
notification.addAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.restoring_backup))
|
||||||
|
.setContentText(getString(R.string.data_restored_with_errors))
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
AppRouter.homeIntent(this@BaseBackupRestoreService),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!isRestoreService && fileUri != null) {
|
||||||
|
val shareIntent = ShareCompat.IntentBuilder(this@BaseBackupRestoreService)
|
||||||
|
.setStream(fileUri)
|
||||||
|
.setType("application/zip")
|
||||||
|
.setChooserTitle(R.string.share_backup)
|
||||||
|
.createChooserIntent()
|
||||||
|
notification.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_menu_share_mtrl_alpha,
|
||||||
|
getString(R.string.share),
|
||||||
|
PendingIntentCompat.getActivity(this@BaseBackupRestoreService, 0, shareIntent, 0, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notificationManager.notify(notificationTag, startId, notification.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun NotificationCompat.Builder.setBigText(title: String, text: CharSequence) = setStyle(
|
||||||
|
NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(text)
|
||||||
|
.setSummaryText(text)
|
||||||
|
.setBigContentTitle(title),
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "backup_restore"
|
||||||
|
|
||||||
|
fun createNotificationChannel(context: Context) {
|
||||||
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||||
|
.setName(context.getString(R.string.backup_restore))
|
||||||
|
.setShowBadge(true)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.FragmentManager
|
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -16,29 +14,14 @@ import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
|||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
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.core.util.ext.tryLaunch
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||||
|
|
||||||
private val viewModel by viewModels<BackupViewModel>()
|
private val viewModel by viewModels<BackupViewModel>()
|
||||||
|
|
||||||
private var backup: File? = null
|
|
||||||
private val saveFileContract = registerForActivityResult(
|
|
||||||
ActivityResultContracts.CreateDocument("application/zip"),
|
|
||||||
) { uri ->
|
|
||||||
val file = backup
|
|
||||||
if (uri != null && file != null) {
|
|
||||||
saveBackup(file, uri)
|
|
||||||
} else {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(
|
override fun onCreateViewBinding(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -69,47 +52,20 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onProgressChanged(value: Float) {
|
private fun onProgressChanged(value: Progress) {
|
||||||
with(requireViewBinding().progressBar) {
|
with(requireViewBinding().progressBar) {
|
||||||
isVisible = true
|
isVisible = true
|
||||||
val wasIndeterminate = isIndeterminate
|
val wasIndeterminate = isIndeterminate
|
||||||
isIndeterminate = value < 0
|
isIndeterminate = value.isIndeterminate
|
||||||
if (value >= 0) {
|
if (!value.isIndeterminate) {
|
||||||
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
|
max = value.total
|
||||||
|
setProgressCompat(value.progress, !wasIndeterminate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onBackupDone(file: File) {
|
private fun onBackupDone(uri: Uri) {
|
||||||
this.backup = file
|
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show()
|
||||||
if (!saveFileContract.tryLaunch(file.name)) {
|
dismiss()
|
||||||
Toast.makeText(requireContext(), R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveBackup(file: File, output: Uri) {
|
|
||||||
try {
|
|
||||||
requireContext().contentResolver.openFileDescriptor(output, "w")?.use { fd ->
|
|
||||||
FileOutputStream(fd.fileDescriptor).use {
|
|
||||||
it.write(file.readBytes())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show()
|
|
||||||
dismiss()
|
|
||||||
} catch (e: InterruptedException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
onError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val TAG = "BackupDialogFragment"
|
|
||||||
|
|
||||||
fun show(fm: FragmentManager) {
|
|
||||||
BackupDialogFragment().show(fm, TAG)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
class BackupService : BaseBackupRestoreService() {
|
||||||
|
|
||||||
|
override val notificationTag = TAG
|
||||||
|
override val isRestoreService = false
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
val notification = buildNotification(Progress.INDETERMINATE)
|
||||||
|
setForeground(
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
val destination = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
||||||
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
launch {
|
||||||
|
progress.collect {
|
||||||
|
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ZipOutputStream(contentResolver.openOutputStream(destination)).use { output ->
|
||||||
|
repository.createBackup(output, progress)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
try {
|
||||||
|
DocumentFile.fromSingleUri(applicationContext, destination)?.delete()
|
||||||
|
} catch (e2: Throwable) {
|
||||||
|
e.addSuppressed(e2)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
progressUpdateJob?.cancelAndJoin()
|
||||||
|
contentResolver.notifyChange(destination, null)
|
||||||
|
showResultNotification(destination, CompositeResult.success())
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@BackupService, R.string.backup_saved, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
|
||||||
|
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.creating_backup))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(
|
||||||
|
progress.total.coerceAtLeast(0),
|
||||||
|
progress.progress.coerceAtLeast(0),
|
||||||
|
progress.isIndeterminate,
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
if (progress.isIndeterminate) {
|
||||||
|
getString(R.string.processing_)
|
||||||
|
} else {
|
||||||
|
getString(R.string.fraction_pattern, progress.progress, progress.total)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "BACKUP"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 33
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun start(context: Context, uri: Uri): Boolean = try {
|
||||||
|
val intent = Intent(context, BackupService::class.java)
|
||||||
|
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
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.core.util.progress.Progress
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BackupViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val repository: BackupRepository,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val onBackupDone = MutableEventFlow<Uri>()
|
||||||
|
|
||||||
|
private val destination = savedStateHandle.require<Uri>(AppRouter.KEY_DATA)
|
||||||
|
private val contentResolver: ContentResolver = context.contentResolver
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
ZipOutputStream(checkNotNull(contentResolver.openOutputStream(destination))).use {
|
||||||
|
it.setLevel(Deflater.BEST_COMPRESSION)
|
||||||
|
repository.createBackup(it, progress)
|
||||||
|
}
|
||||||
|
onBackupDone.call(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||||
|
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
||||||
|
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class PeriodicalBackupService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var externalBackupStorage: ExternalBackupStorage
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var telegramBackupUploader: TelegramBackupUploader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
if (!settings.isPeriodicalBackupEnabled || settings.periodicalBackupDirectory == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lastBackupDate = externalBackupStorage.getLastBackupDate()
|
||||||
|
if (lastBackupDate != null && lastBackupDate.time + settings.periodicalBackupFrequencyMillis > System.currentTimeMillis()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val output = BackupUtils.createTempFile(applicationContext)
|
||||||
|
try {
|
||||||
|
ZipOutputStream(output.outputStream()).use {
|
||||||
|
repository.createBackup(it, null)
|
||||||
|
}
|
||||||
|
externalBackupStorage.put(output)
|
||||||
|
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
|
||||||
|
if (settings.isBackupTelegramUploadEnabled && telegramBackupUploader.isAvailable) {
|
||||||
|
telegramBackupUploader.uploadBackup(output)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
output.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
|
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
BaseBackupRestoreService.createNotificationChannel(applicationContext)
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
val title = getString(R.string.periodic_backups)
|
||||||
|
val message = getString(
|
||||||
|
R.string.inline_preference_pattern,
|
||||||
|
getString(R.string.packup_creation_failed),
|
||||||
|
error.getDisplayMessage(resources),
|
||||||
|
)
|
||||||
|
notification
|
||||||
|
.setContentText(message)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.setStyle(
|
||||||
|
NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(message)
|
||||||
|
.setSummaryText(getString(R.string.packup_creation_failed))
|
||||||
|
.setBigContentTitle(title),
|
||||||
|
)
|
||||||
|
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, TAG)?.let { action ->
|
||||||
|
notification.addAction(action)
|
||||||
|
}
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
AppRouter.periodicBackupSettingsIntent(applicationContext),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
NotificationManagerCompat.from(applicationContext).notify(TAG, startId, notification.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = BaseBackupRestoreService.CHANNEL_ID
|
||||||
|
const val TAG = "periodical_backup"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.result.ActivityResultCallback
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceCategory
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
|
import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||||
|
import org.koitharu.kotatsu.settings.utils.EditTextFallbackSummaryProvider
|
||||||
|
import java.util.Date
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups),
|
||||||
|
ActivityResultCallback<Uri?> {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var telegramBackupUploader: TelegramBackupUploader
|
||||||
|
|
||||||
|
private val viewModel by viewModels<PeriodicalBackupSettingsViewModel>()
|
||||||
|
|
||||||
|
private val outputSelectCall = OpenDocumentTreeHelper(this, this)
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
addPreferencesFromResource(R.xml.pref_backup_periodic)
|
||||||
|
findPreference<PreferenceCategory>(AppSettings.KEY_BACKUP_TG)?.isVisible = viewModel.isTelegramAvailable
|
||||||
|
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
|
||||||
|
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
viewModel.lastBackupDate.observe(viewLifecycleOwner, ::bindLastBackupInfo)
|
||||||
|
viewModel.backupsDirectory.observe(viewLifecycleOwner, ::bindOutputSummary)
|
||||||
|
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
||||||
|
viewModel.isTelegramCheckLoading.observe(viewLifecycleOwner) {
|
||||||
|
findPreference<Preference>(AppSettings.KEY_BACKUP_TG_TEST)?.isEnabled = !it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||||
|
val result = when (preference.key) {
|
||||||
|
AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null)
|
||||||
|
AppSettings.KEY_BACKUP_TG_OPEN -> telegramBackupUploader.openBotInApp(router)
|
||||||
|
AppSettings.KEY_BACKUP_TG_TEST -> {
|
||||||
|
viewModel.checkTelegram()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> return super.onPreferenceTreeClick(preference)
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(result: Uri?) {
|
||||||
|
if (result != null) {
|
||||||
|
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
|
||||||
|
settings.periodicalBackupDirectory = result
|
||||||
|
viewModel.updateSummaryData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindOutputSummary(path: String?) {
|
||||||
|
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
|
||||||
|
preference.summary = when (path) {
|
||||||
|
null -> getString(R.string.invalid_value_message)
|
||||||
|
"" -> null
|
||||||
|
else -> path
|
||||||
|
}
|
||||||
|
preference.icon = if (path == null) {
|
||||||
|
getWarningIcon()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindLastBackupInfo(lastBackupDate: Date?) {
|
||||||
|
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
|
||||||
|
preference.summary = lastBackupDate?.let {
|
||||||
|
preference.context.getString(
|
||||||
|
R.string.last_successful_backup,
|
||||||
|
DateUtils.getRelativeTimeSpanString(it.time),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
preference.isVisible = lastBackupDate != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||||
|
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
||||||
|
import java.util.Date
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class PeriodicalBackupSettingsViewModel @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val telegramUploader: TelegramBackupUploader,
|
||||||
|
private val backupStorage: ExternalBackupStorage,
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val isTelegramAvailable
|
||||||
|
get() = telegramUploader.isAvailable
|
||||||
|
|
||||||
|
val lastBackupDate = MutableStateFlow<Date?>(null)
|
||||||
|
val backupsDirectory = MutableStateFlow<String?>("")
|
||||||
|
val isTelegramCheckLoading = MutableStateFlow(false)
|
||||||
|
val onActionDone = MutableEventFlow<ReversibleAction>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
updateSummaryData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkTelegram() {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
isTelegramCheckLoading.value = true
|
||||||
|
telegramUploader.sendTestMessage()
|
||||||
|
onActionDone.call(ReversibleAction(R.string.connection_ok, null))
|
||||||
|
} finally {
|
||||||
|
isTelegramCheckLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSummaryData() {
|
||||||
|
updateBackupsDirectory()
|
||||||
|
updateLastBackupDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBackupsDirectory() = launchJob(Dispatchers.Default) {
|
||||||
|
val dir = settings.periodicalBackupDirectory
|
||||||
|
backupsDirectory.value = if (dir != null) {
|
||||||
|
dir.toUserFriendlyString()
|
||||||
|
} else {
|
||||||
|
BackupUtils.getAppBackupDir(appContext).path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLastBackupDate() = launchJob(Dispatchers.Default) {
|
||||||
|
lastBackupDate.value = backupStorage.getLastBackupDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Uri.toUserFriendlyString(): String? {
|
||||||
|
val df = DocumentFile.fromTreeUri(appContext, this)
|
||||||
|
if (df?.canWrite() != true) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return resolveFile(appContext)?.path ?: toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.internal.closeQuietly
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||||
|
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TelegramBackupUploader @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
@BaseHttpClient private val client: OkHttpClient,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val botToken = context.getString(R.string.tg_backup_bot_token)
|
||||||
|
|
||||||
|
val isAvailable: Boolean
|
||||||
|
get() = botToken.isNotEmpty()
|
||||||
|
|
||||||
|
suspend fun uploadBackup(file: File) {
|
||||||
|
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||||
|
val multipartBody = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.Companion.FORM)
|
||||||
|
.addFormDataPart("chat_id", requireChatId())
|
||||||
|
.addFormDataPart("document", file.name, requestBody)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(urlOf("sendDocument").build())
|
||||||
|
.post(multipartBody)
|
||||||
|
.build()
|
||||||
|
client.newCall(request).await().consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendTestMessage() {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(urlOf("getMe").build())
|
||||||
|
.build()
|
||||||
|
client.newCall(request).await().consume()
|
||||||
|
sendMessage(context.getString(R.string.backup_tg_echo))
|
||||||
|
}
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun openBotInApp(router: AppRouter): Boolean {
|
||||||
|
val botUsername = context.getString(R.string.tg_backup_bot_name)
|
||||||
|
return router.openExternalBrowser("tg://resolve?domain=$botUsername") ||
|
||||||
|
router.openExternalBrowser("https://t.me/$botUsername")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendMessage(message: String) {
|
||||||
|
val url = urlOf("sendMessage")
|
||||||
|
.addQueryParameter("chat_id", requireChatId())
|
||||||
|
.addQueryParameter("text", message)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.build()
|
||||||
|
client.newCall(request).await().consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) {
|
||||||
|
"Telegram chat ID not set in settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.consume() {
|
||||||
|
if (isSuccessful) {
|
||||||
|
closeQuietly()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val jo = parseJson()
|
||||||
|
if (!jo.getBooleanOrDefault("ok", true)) {
|
||||||
|
throw RuntimeException(jo.getStringOrNull("description"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun urlOf(method: String) = HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host("api.telegram.org")
|
||||||
|
.addPathSegment("bot$botToken")
|
||||||
|
.addPathSegment(method)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
@@ -8,18 +8,18 @@ import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
|||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
|
||||||
class BackupEntriesAdapter(
|
class BackupSectionsAdapter(
|
||||||
clickListener: OnListItemClickListener<BackupEntryModel>,
|
clickListener: OnListItemClickListener<BackupSectionModel>,
|
||||||
) : BaseListAdapter<BackupEntryModel>() {
|
) : BaseListAdapter<BackupSectionModel>() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addDelegate(ListItemType.NAV_ITEM, backupEntryAD(clickListener))
|
addDelegate(ListItemType.NAV_ITEM, backupSectionAD(clickListener))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun backupEntryAD(
|
private fun backupSectionAD(
|
||||||
clickListener: OnListItemClickListener<BackupEntryModel>,
|
clickListener: OnListItemClickListener<BackupSectionModel>,
|
||||||
) = adapterDelegateViewBinding<BackupEntryModel, BackupEntryModel, ItemCheckableMultipleBinding>(
|
) = adapterDelegateViewBinding<BackupSectionModel, BackupSectionModel, ItemCheckableMultipleBinding>(
|
||||||
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
|
data class BackupSectionModel(
|
||||||
|
val section: BackupSection,
|
||||||
|
val isChecked: Boolean,
|
||||||
|
val isEnabled: Boolean,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val titleResId: Int
|
||||||
|
get() = when (section) {
|
||||||
|
BackupSection.INDEX -> 0 // should not appear here
|
||||||
|
BackupSection.HISTORY -> R.string.history
|
||||||
|
BackupSection.CATEGORIES -> R.string.favourites_categories
|
||||||
|
BackupSection.FAVOURITES -> R.string.favourites
|
||||||
|
BackupSection.SETTINGS -> R.string.settings
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
||||||
|
BackupSection.BOOKMARKS -> R.string.bookmarks
|
||||||
|
BackupSection.SOURCES -> R.string.remote_sources
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is BackupSectionModel && other.section == section
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(previousState: ListModel): Any? {
|
||||||
|
if (previousState !is BackupSectionModel) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return if (previousState.isEnabled != isEnabled) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
|
||||||
|
} else if (previousState.isChecked != isChecked) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
||||||
|
} else {
|
||||||
|
super.getChangePayload(previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,31 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.FragmentManager
|
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
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.core.util.ext.textAndVisible
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
|
||||||
import org.koitharu.kotatsu.databinding.DialogRestoreBinding
|
import org.koitharu.kotatsu.databinding.DialogRestoreBinding
|
||||||
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
|
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupEntryModel>,
|
class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupSectionModel>,
|
||||||
View.OnClickListener {
|
View.OnClickListener {
|
||||||
|
|
||||||
private val viewModel: RestoreViewModel by viewModels()
|
private val viewModel: RestoreViewModel by viewModels()
|
||||||
@@ -41,13 +37,11 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
|||||||
|
|
||||||
override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
val adapter = BackupEntriesAdapter(this)
|
val adapter = BackupSectionsAdapter(this)
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
binding.buttonCancel.setOnClickListener(this)
|
binding.buttonCancel.setOnClickListener(this)
|
||||||
binding.buttonRestore.setOnClickListener(this)
|
binding.buttonRestore.setOnClickListener(this)
|
||||||
viewModel.availableEntries.observe(viewLifecycleOwner, adapter)
|
viewModel.availableEntries.observe(viewLifecycleOwner, adapter)
|
||||||
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
|
|
||||||
viewModel.onRestoreDone.observeEvent(viewLifecycleOwner, this::onRestoreDone)
|
|
||||||
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
|
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
|
||||||
combine(
|
combine(
|
||||||
viewModel.isLoading,
|
viewModel.isLoading,
|
||||||
@@ -66,15 +60,23 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
|||||||
override fun onClick(v: View) {
|
override fun onClick(v: View) {
|
||||||
when (v.id) {
|
when (v.id) {
|
||||||
R.id.button_cancel -> dismiss()
|
R.id.button_cancel -> dismiss()
|
||||||
R.id.button_restore -> viewModel.restore()
|
R.id.button_restore -> {
|
||||||
|
if (startRestoreService()) {
|
||||||
|
Toast.makeText(v.context, R.string.backup_restored_background, Toast.LENGTH_SHORT).show()
|
||||||
|
router.closeWelcomeSheet()
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: BackupEntryModel, view: View) {
|
override fun onItemClick(item: BackupSectionModel, view: View) {
|
||||||
viewModel.onItemClick(item)
|
viewModel.onItemClick(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onLoadingChanged(value: Triple<Boolean, List<BackupEntryModel>, Date?>) {
|
private fun onLoadingChanged(value: Triple<Boolean, List<BackupSectionModel>, Date?>) {
|
||||||
val (isLoading, entries, backupDate) = value
|
val (isLoading, entries, backupDate) = value
|
||||||
val hasEntries = entries.isNotEmpty()
|
val hasEntries = entries.isNotEmpty()
|
||||||
with(requireViewBinding()) {
|
with(requireViewBinding()) {
|
||||||
@@ -90,6 +92,14 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startRestoreService(): Boolean {
|
||||||
|
return RestoreService.start(
|
||||||
|
context ?: return false,
|
||||||
|
viewModel.uri ?: return false,
|
||||||
|
viewModel.getCheckedSections(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun Date.formatBackupDate(): String {
|
private fun Date.formatBackupDate(): String {
|
||||||
return getString(
|
return getString(
|
||||||
R.string.backup_date_,
|
R.string.backup_date_,
|
||||||
@@ -105,59 +115,4 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
|||||||
.show()
|
.show()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onProgressChanged(value: Float) {
|
|
||||||
with(requireViewBinding().progressBar) {
|
|
||||||
isVisible = true
|
|
||||||
val wasIndeterminate = isIndeterminate
|
|
||||||
isIndeterminate = value < 0
|
|
||||||
if (value >= 0) {
|
|
||||||
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onRestoreDone(result: CompositeResult) {
|
|
||||||
val builder = MaterialAlertDialogBuilder(context ?: return)
|
|
||||||
when {
|
|
||||||
result.isEmpty -> {
|
|
||||||
builder.setTitle(R.string.data_not_restored)
|
|
||||||
.setMessage(R.string.data_not_restored_text)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.isAllSuccess -> {
|
|
||||||
builder.setTitle(R.string.data_restored)
|
|
||||||
.setMessage(R.string.data_restored_success)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.isAllFailed -> builder.setTitle(R.string.error)
|
|
||||||
.setMessage(
|
|
||||||
result.failures.map {
|
|
||||||
it.getDisplayMessage(resources)
|
|
||||||
}.distinct().joinToString("\n"),
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> builder.setTitle(R.string.data_restored)
|
|
||||||
.setMessage(R.string.data_restored_with_errors)
|
|
||||||
}
|
|
||||||
builder.setPositiveButton(android.R.string.ok, null)
|
|
||||||
.show()
|
|
||||||
if (!result.isEmpty && !result.isAllFailed) {
|
|
||||||
WelcomeSheet.dismiss(parentFragmentManager)
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val ARG_FILE = "file"
|
|
||||||
private const val TAG = "RestoreDialogFragment"
|
|
||||||
|
|
||||||
fun show(fm: FragmentManager, uri: Uri) {
|
|
||||||
RestoreDialogFragment().withArgs(1) {
|
|
||||||
putString(ARG_FILE, uri.toString())
|
|
||||||
}.show(fm, TAG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
class RestoreService : BaseBackupRestoreService() {
|
||||||
|
|
||||||
|
override val notificationTag = TAG
|
||||||
|
override val isRestoreService = true
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
val notification = buildNotification(Progress.INDETERMINATE)
|
||||||
|
setForeground(
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
val source = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
||||||
|
val sections =
|
||||||
|
requireNotNull(intent.getSerializableExtraCompat<Array<BackupSection>>(AppRouter.KEY_ENTRIES)?.toSet())
|
||||||
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
launch {
|
||||||
|
progress.collect {
|
||||||
|
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val result = ZipInputStream(contentResolver.openInputStream(source)).use { input ->
|
||||||
|
repository.restoreBackup(input, sections, progress)
|
||||||
|
}
|
||||||
|
progressUpdateJob?.cancelAndJoin()
|
||||||
|
showResultNotification(source, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
|
||||||
|
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.restoring_backup))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(
|
||||||
|
progress.total.coerceAtLeast(0),
|
||||||
|
progress.progress.coerceAtLeast(0),
|
||||||
|
progress.isIndeterminate,
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
if (progress.isIndeterminate) {
|
||||||
|
getString(R.string.processing_)
|
||||||
|
} else {
|
||||||
|
getString(R.string.fraction_pattern, progress.progress, progress.total)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "RESTORE"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 39
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun start(context: Context, uri: Uri, sections: Set<BackupSection>): Boolean = try {
|
||||||
|
val intent = Intent(context, RestoreService::class.java)
|
||||||
|
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
||||||
|
intent.putExtra(AppRouter.KEY_ENTRIES, sections.toTypedArray())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BackupIndex
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.EnumMap
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RestoreViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE)?.toUriOrNull()
|
||||||
|
private val contentResolver = context.contentResolver
|
||||||
|
|
||||||
|
val availableEntries = MutableStateFlow<List<BackupSectionModel>>(emptyList())
|
||||||
|
val backupDate = MutableStateFlow<Date?>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
loadBackupInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadBackupInfo() {
|
||||||
|
val sections = runInterruptible(Dispatchers.IO) {
|
||||||
|
if (uri == null) throw FileNotFoundException()
|
||||||
|
ZipInputStream(contentResolver.openInputStream(uri)).use { stream ->
|
||||||
|
val result = EnumSet.noneOf(BackupSection::class.java)
|
||||||
|
var entry = stream.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val s = BackupSection.of(entry)
|
||||||
|
if (s != null) {
|
||||||
|
result.add(s)
|
||||||
|
if (s == BackupSection.INDEX) {
|
||||||
|
backupDate.value = stream.readDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.closeEntry()
|
||||||
|
entry = stream.nextEntry
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
availableEntries.value = BackupSection.entries.mapNotNull { entry ->
|
||||||
|
if (entry == BackupSection.INDEX || entry !in sections) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
BackupSectionModel(
|
||||||
|
section = entry,
|
||||||
|
isChecked = true,
|
||||||
|
isEnabled = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onItemClick(item: BackupSectionModel) {
|
||||||
|
val map = availableEntries.value.associateByTo(EnumMap(BackupSection::class.java)) { it.section }
|
||||||
|
map[item.section] = item.copy(isChecked = !item.isChecked)
|
||||||
|
map.validate()
|
||||||
|
availableEntries.value = map.values.sortedBy { it.section.ordinal }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCheckedSections(): Set<BackupSection> = availableEntries.value
|
||||||
|
.mapNotNullTo(EnumSet.noneOf(BackupSection::class.java)) {
|
||||||
|
if (it.isChecked) it.section else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for inconsistent user selection
|
||||||
|
* Favorites cannot be restored without categories
|
||||||
|
*/
|
||||||
|
private fun MutableMap<BackupSection, BackupSectionModel>.validate() {
|
||||||
|
val favorites = this[BackupSection.FAVOURITES] ?: return
|
||||||
|
val categories = this[BackupSection.CATEGORIES]
|
||||||
|
if (categories?.isChecked == true) {
|
||||||
|
if (!favorites.isEnabled) {
|
||||||
|
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (favorites.isEnabled) {
|
||||||
|
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readDate(): Date? = runCatching {
|
||||||
|
val index = Json.decodeFromStream<List<BackupIndex>>(this)
|
||||||
|
Date(index.single().createdAt)
|
||||||
|
}.onFailure { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ import androidx.room.Insert
|
|||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -17,9 +20,9 @@ abstract class BookmarksDao {
|
|||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
|
abstract suspend fun findAll(offset: Int, limit: Int): Map<MangaWithTags, List<BookmarkEntity>>
|
||||||
|
|
||||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
||||||
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||||
@@ -47,4 +50,17 @@ abstract class BookmarksDao {
|
|||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
|
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
|
||||||
|
|
||||||
|
fun dump(): Flow<Pair<MangaWithTags, List<BookmarkEntity>>> = flow {
|
||||||
|
val window = 4
|
||||||
|
var offset = 0
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val list = findAll(offset, window)
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += window
|
||||||
|
list.forEach { emit(it.key to it.value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.domain
|
package org.koitharu.kotatsu.bookmarks.domain
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isImage
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.local.data.hasImageExtension
|
|
||||||
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 java.time.Instant
|
import java.time.Instant
|
||||||
@@ -17,9 +18,6 @@ data class Bookmark(
|
|||||||
val percent: Float,
|
val percent: Float,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
val imageLoadData: Any
|
|
||||||
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
return other is Bookmark &&
|
return other is Bookmark &&
|
||||||
manga.id == other.manga.id &&
|
manga.id == other.manga.id &&
|
||||||
@@ -30,11 +28,9 @@ data class Bookmark(
|
|||||||
fun toMangaPage() = MangaPage(
|
fun toMangaPage() = MangaPage(
|
||||||
id = pageId,
|
id = pageId,
|
||||||
url = imageUrl,
|
url = imageUrl,
|
||||||
preview = null,
|
preview = imageUrl.takeIf {
|
||||||
|
MimeTypes.getMimeTypeFromUrl(it)?.isImage == true
|
||||||
|
},
|
||||||
source = manga.source,
|
source = manga.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun isImageUrlDirect(): Boolean {
|
|
||||||
return hasImageExtension(imageUrl)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui
|
package org.koitharu.kotatsu.bookmarks.ui
|
||||||
|
|
||||||
import android.content.Context
|
import org.koitharu.kotatsu.core.ui.FragmentContainerActivity
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.commit
|
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
|
||||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
class AllBookmarksActivity : FragmentContainerActivity(AllBookmarksFragment::class.java)
|
||||||
class AllBookmarksActivity :
|
|
||||||
BaseActivity<ActivityContainerBinding>(),
|
|
||||||
AppBarOwner,
|
|
||||||
SnackbarOwner {
|
|
||||||
|
|
||||||
override val appBar: AppBarLayout
|
|
||||||
get() = viewBinding.appbar
|
|
||||||
|
|
||||||
override val snackbarHost: CoordinatorLayout
|
|
||||||
get() = viewBinding.root
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
val fm = supportFragmentManager
|
|
||||||
if (fm.findFragmentById(R.id.container) == null) {
|
|
||||||
fm.commit {
|
|
||||||
setReorderingAllowed(true)
|
|
||||||
replace(R.id.container, AllBookmarksFragment::class.java, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,28 +9,28 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import coil3.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.adapter.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.nav.ReaderIntent
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
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
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
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.consumeAllSystemBarsInsets
|
||||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
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.core.util.ext.systemBarsInsets
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
|
||||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
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
|
||||||
@@ -39,7 +39,7 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -50,16 +50,22 @@ class AllBookmarksFragment :
|
|||||||
ListSelectionController.Callback,
|
ListSelectionController.Callback,
|
||||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
FastScroller.FastScrollListener, ListHeaderClickListener {
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var coil: ImageLoader
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var settings: AppSettings
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
|
||||||
|
|
||||||
|
private lateinit var pageSaveHelper: PageSaveHelper
|
||||||
private val viewModel by viewModels<AllBookmarksViewModel>()
|
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
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateViewBinding(
|
override fun onCreateViewBinding(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -79,8 +85,6 @@ class AllBookmarksFragment :
|
|||||||
callback = this,
|
callback = this,
|
||||||
)
|
)
|
||||||
bookmarksAdapter = BookmarksAdapter(
|
bookmarksAdapter = BookmarksAdapter(
|
||||||
lifecycleOwner = viewLifecycleOwner,
|
|
||||||
coil = coil,
|
|
||||||
clickListener = this,
|
clickListener = this,
|
||||||
headerClickListener = this,
|
headerClickListener = this,
|
||||||
)
|
)
|
||||||
@@ -107,6 +111,18 @@ class AllBookmarksFragment :
|
|||||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||||
|
val barsInsets = insets.systemBarsInsets
|
||||||
|
val basePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
|
||||||
|
viewBinding?.recyclerView?.setPadding(
|
||||||
|
barsInsets.left + basePadding,
|
||||||
|
barsInsets.top + basePadding,
|
||||||
|
barsInsets.right + basePadding,
|
||||||
|
barsInsets.bottom + basePadding,
|
||||||
|
)
|
||||||
|
return insets.consumeAllSystemBarsInsets()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
bookmarksAdapter = null
|
bookmarksAdapter = null
|
||||||
@@ -115,26 +131,26 @@ class AllBookmarksFragment :
|
|||||||
|
|
||||||
override fun onItemClick(item: Bookmark, view: View) {
|
override fun onItemClick(item: Bookmark, view: View) {
|
||||||
if (selectionController?.onItemClick(item.pageId) != true) {
|
if (selectionController?.onItemClick(item.pageId) != true) {
|
||||||
val intent = ReaderActivity.IntentBuilder(view.context)
|
val intent = ReaderIntent.Builder(view.context)
|
||||||
.bookmark(item)
|
.bookmark(item)
|
||||||
.incognito(true)
|
.incognito()
|
||||||
.build()
|
.build()
|
||||||
startActivity(intent)
|
router.openReader(intent)
|
||||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onListHeaderClick(item: ListHeader, view: View) {
|
override fun onListHeaderClick(item: ListHeader, view: View) {
|
||||||
val manga = item.payload as? Manga ?: return
|
val manga = item.payload as? Manga ?: return
|
||||||
startActivity(DetailsActivity.newIntent(view.context, manga))
|
router.openDetails(manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||||
return selectionController?.onItemLongClick(view, item.pageId) ?: false
|
return selectionController?.onItemLongClick(view, item.pageId) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
||||||
return selectionController?.onItemContextClick(view, item.pageId) ?: false
|
return selectionController?.onItemContextClick(view, item.pageId) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRetryClick(error: Throwable) = Unit
|
override fun onRetryClick(error: Throwable) = Unit
|
||||||
@@ -173,17 +189,13 @@ class AllBookmarksFragment :
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
R.id.action_save -> {
|
||||||
}
|
viewModel.savePages(pageSaveHelper, selectionController?.snapshot() ?: return false)
|
||||||
}
|
mode?.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
else -> false
|
||||||
val rv = requireViewBinding().recyclerView
|
|
||||||
rv.updatePadding(
|
|
||||||
bottom = insets.bottom + rv.paddingTop,
|
|
||||||
)
|
|
||||||
rv.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
bottomMargin = insets.bottom
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,16 +220,4 @@ class AllBookmarksFragment :
|
|||||||
invalidateSpanIndexCache()
|
invalidateSpanIndexCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
"",
|
|
||||||
ReplaceWith(
|
|
||||||
"BookmarksFragment()",
|
|
||||||
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
fun newInstance() = AllBookmarksFragment()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
|
|||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -56,6 +57,23 @@ class AllBookmarksViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun savePages(pageSaveHelper: PageSaveHelper, ids: Set<Long>) {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
val tasks = content.value.mapNotNull {
|
||||||
|
if (it !is Bookmark || it.pageId !in ids) return@mapNotNull null
|
||||||
|
PageSaveHelper.Task(
|
||||||
|
manga = it.manga,
|
||||||
|
chapterId = it.chapterId,
|
||||||
|
pageNumber = it.page + 1,
|
||||||
|
page = it.toMangaPage(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val dest = pageSaveHelper.save(tasks)
|
||||||
|
val msg = if (dest.size == 1) R.string.page_saved else R.string.pages_saved
|
||||||
|
onActionDone.call(ReversibleAction(msg, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
|
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
|
||||||
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
|
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
|
||||||
for ((manga, bookmarks) in data) {
|
for ((manga, bookmarks) in data) {
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import coil3.request.allowRgb565
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
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.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.bookmarkExtra
|
|
||||||
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.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
fun bookmarkLargeAD(
|
fun bookmarkLargeAD(
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
||||||
@@ -26,14 +15,7 @@ fun bookmarkLargeAD(
|
|||||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
binding.imageViewThumb.setImageAsync(item)
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
|
||||||
defaultPlaceholders(context)
|
|
||||||
allowRgb565(true)
|
|
||||||
bookmarkExtra(item)
|
|
||||||
decodeRegion(item.scroll)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
binding.progressView.setProgress(item.percent, false)
|
binding.progressView.setProgress(item.percent, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import coil3.request.allowRgb565
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
|
||||||
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.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
|
||||||
|
|
||||||
// TODO check usages
|
|
||||||
fun bookmarkListAD(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
|
||||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
|
||||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
|
||||||
) {
|
|
||||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
|
||||||
|
|
||||||
bind {
|
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
|
||||||
defaultPlaceholders(context)
|
|
||||||
allowRgb565(true)
|
|
||||||
bookmarkExtra(item)
|
|
||||||
decodeRegion(item.scroll)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil3.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
|
||||||
@@ -10,24 +8,24 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
|||||||
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.emptyStateListAD
|
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
class BookmarksAdapter(
|
class BookmarksAdapter(
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
headerClickListener: ListHeaderClickListener?,
|
headerClickListener: ListHeaderClickListener?,
|
||||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(clickListener))
|
||||||
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
||||||
|
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
|
||||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AdListUpdateService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var updater: AdBlock.Updater
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
updater.updateList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun IntentJobContext.onError(error: Throwable) = Unit
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||||
|
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.consumeAll
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var proxyProvider: ProxyProvider
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var adBlock: AdBlock
|
||||||
|
|
||||||
|
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||||
|
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||||
|
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||||
|
|
||||||
|
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
|
||||||
|
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||||
|
val userAgent = intent?.getStringExtra(AppRouter.KEY_USER_AGENT)?.nullIfEmpty()
|
||||||
|
?: repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||||
|
viewBinding.webView.configureForParser(userAgent)
|
||||||
|
|
||||||
|
onCreate2(savedInstanceState, mangaSource, repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun onCreate2(
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
source: MangaSource,
|
||||||
|
repository: ParserMangaRepository?
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onApplyWindowInsets(
|
||||||
|
v: View,
|
||||||
|
insets: WindowInsetsCompat
|
||||||
|
): WindowInsetsCompat {
|
||||||
|
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
|
||||||
|
val barsInsets = insets.getInsets(type)
|
||||||
|
viewBinding.webView.updatePadding(
|
||||||
|
left = barsInsets.left,
|
||||||
|
right = barsInsets.right,
|
||||||
|
bottom = barsInsets.bottom,
|
||||||
|
)
|
||||||
|
viewBinding.appbar.updatePadding(
|
||||||
|
left = barsInsets.left,
|
||||||
|
right = barsInsets.right,
|
||||||
|
top = barsInsets.top,
|
||||||
|
)
|
||||||
|
return insets.consumeAll(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
viewBinding.webView.onPause()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
viewBinding.webView.onResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
if (hasViewBinding()) {
|
||||||
|
viewBinding.webView.stopLoading()
|
||||||
|
viewBinding.webView.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
|
viewBinding.progressBar.isVisible = isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||||
|
this.title = title
|
||||||
|
supportActionBar?.subtitle = subtitle
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHistoryChanged() {
|
||||||
|
onBackPressedCallback.onHistoryChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,68 +1,49 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.webkit.CookieManager
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.core.graphics.Insets
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.core.view.isVisible
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import javax.inject.Inject
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
class BrowserActivity : BaseBrowserActivity() {
|
||||||
|
|
||||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||||
|
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||||
@Inject
|
viewBinding.webView.webViewClient = BrowserClient(this, adBlock)
|
||||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
proxyProvider.applyWebViewConfig()
|
||||||
super.onCreate(savedInstanceState)
|
} catch (e: Exception) {
|
||||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
e.printStackTraceDebug()
|
||||||
return
|
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
supportActionBar?.run {
|
if (savedInstanceState == null) {
|
||||||
setDisplayHomeAsUpEnabled(true)
|
val url = intent?.dataString
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
if (url.isNullOrEmpty()) {
|
||||||
}
|
finishAfterTransition()
|
||||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
} else {
|
||||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
onTitleChanged(
|
||||||
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
|
||||||
viewBinding.webView.configureForParser(userAgent)
|
url,
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
viewBinding.webView.loadUrl(url)
|
||||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
}
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
}
|
||||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
|
||||||
if (savedInstanceState != null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val url = intent?.dataString
|
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
finishAfterTransition()
|
|
||||||
} else {
|
|
||||||
onTitleChanged(
|
|
||||||
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
|
|
||||||
url,
|
|
||||||
)
|
|
||||||
viewBinding.webView.loadUrl(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,14 +61,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_browser -> {
|
R.id.action_browser -> {
|
||||||
val url = viewBinding.webView.url?.toUriOrNull()
|
if (!router.openExternalBrowser(viewBinding.webView.url.orEmpty(), item.title)) {
|
||||||
if (url != null) {
|
Snackbar.make(viewBinding.webView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
|
||||||
intent.data = url
|
|
||||||
try {
|
|
||||||
startActivity(Intent.createChooser(intent, item.title))
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -95,58 +70,22 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
class Contract : ActivityResultContract<InteractiveActionRequiredException, Unit>() {
|
||||||
viewBinding.webView.onPause()
|
override fun createIntent(
|
||||||
super.onPause()
|
context: Context,
|
||||||
}
|
input: InteractiveActionRequiredException
|
||||||
|
): Intent = AppRouter.browserIntent(
|
||||||
override fun onResume() {
|
context = context,
|
||||||
super.onResume()
|
url = input.url,
|
||||||
viewBinding.webView.onResume()
|
source = input.source,
|
||||||
}
|
title = null,
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
if (hasViewBinding()) {
|
|
||||||
viewBinding.webView.stopLoading()
|
|
||||||
viewBinding.webView.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
|
||||||
viewBinding.progressBar.isVisible = isLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
|
||||||
this.title = title
|
|
||||||
supportActionBar?.subtitle = subtitle
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onHistoryChanged() {
|
|
||||||
onBackPressedCallback.onHistoryChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.appbar.updatePadding(
|
|
||||||
top = insets.top,
|
|
||||||
)
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
bottom = insets.bottom,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): Unit = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val EXTRA_TITLE = "title"
|
const val TAG = "BrowserActivity"
|
||||||
private const val EXTRA_SOURCE = "source"
|
|
||||||
|
|
||||||
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
|
|
||||||
return Intent(context, BrowserActivity::class.java)
|
|
||||||
.setData(Uri.parse(url))
|
|
||||||
.putExtra(EXTRA_TITLE, title)
|
|
||||||
.putExtra(EXTRA_SOURCE, source?.name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
|
open class BrowserClient(
|
||||||
|
private val callback: BrowserCallback,
|
||||||
|
private val adBlock: AdBlock?,
|
||||||
|
) : WebViewClient() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/57414530/illegalstateexception-reasonphrase-cant-be-empty-with-android-webview
|
||||||
|
*/
|
||||||
|
|
||||||
override fun onPageFinished(webView: WebView, url: String) {
|
override fun onPageFinished(webView: WebView, url: String) {
|
||||||
super.onPageFinished(webView, url)
|
super.onPageFinished(webView, url)
|
||||||
@@ -16,7 +33,7 @@ open class BrowserClient(private val callback: BrowserCallback) : WebViewClient(
|
|||||||
callback.onLoadingStateChanged(isLoading = true)
|
callback.onLoadingStateChanged(isLoading = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
override fun onPageCommitVisible(view: WebView, url: String) {
|
||||||
super.onPageCommitVisible(view, url)
|
super.onPageCommitVisible(view, url)
|
||||||
callback.onTitleChanged(view.title.orEmpty(), url)
|
callback.onTitleChanged(view.title.orEmpty(), url)
|
||||||
}
|
}
|
||||||
@@ -25,4 +42,39 @@ open class BrowserClient(private val callback: BrowserCallback) : WebViewClient(
|
|||||||
super.doUpdateVisitedHistory(view, url, isReload)
|
super.doUpdateVisitedHistory(view, url, isReload)
|
||||||
callback.onHistoryChanged()
|
callback.onHistoryChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun shouldInterceptRequest(
|
||||||
|
view: WebView?,
|
||||||
|
url: String?
|
||||||
|
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock?.shouldLoadUrl(url, view?.getUrlSafe()) ?: true) {
|
||||||
|
super.shouldInterceptRequest(view, url)
|
||||||
|
} else {
|
||||||
|
emptyResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
override fun shouldInterceptRequest(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?
|
||||||
|
): WebResourceResponse? =
|
||||||
|
if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
|
||||||
|
super.shouldInterceptRequest(view, request)
|
||||||
|
} else {
|
||||||
|
emptyResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emptyResponse(): WebResourceResponse =
|
||||||
|
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
|
||||||
|
|
||||||
|
@SuppressLint("WrongThread")
|
||||||
|
@AnyThread
|
||||||
|
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
runBlocking(Dispatchers.Main.immediate) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
|
||||||
|
|
||||||
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.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import coil3.EventListener
|
|
||||||
import coil3.Extras
|
|
||||||
import coil3.request.ErrorResult
|
|
||||||
import coil3.request.ImageRequest
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
class CaptchaNotifier(
|
|
||||||
private val context: Context,
|
|
||||||
) : EventListener() {
|
|
||||||
|
|
||||||
fun notify(exception: CloudFlareProtectedException) {
|
|
||||||
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val manager = NotificationManagerCompat.from(context)
|
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
|
||||||
.setName(context.getString(R.string.captcha_required))
|
|
||||||
.setShowBadge(true)
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.setSound(null, null)
|
|
||||||
.setLightsEnabled(false)
|
|
||||||
.build()
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
|
|
||||||
val intent = CloudFlareActivity.newIntent(context, exception)
|
|
||||||
.setData(exception.url.toUri())
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
||||||
.setContentTitle(channel.name)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSmallIcon(R.drawable.ic_bot)
|
|
||||||
.setGroup(GROUP_CAPTCHA)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setVisibility(
|
|
||||||
if (exception.source?.isNsfw() == true) {
|
|
||||||
NotificationCompat.VISIBILITY_SECRET
|
|
||||||
} else {
|
|
||||||
NotificationCompat.VISIBILITY_PUBLIC
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setContentText(
|
|
||||||
context.getString(
|
|
||||||
R.string.captcha_required_summary,
|
|
||||||
exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
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) {
|
|
||||||
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
|
||||||
super.onError(request, result)
|
|
||||||
val e = result.throwable
|
|
||||||
if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) {
|
|
||||||
notify(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
|
||||||
extras[ignoreCaptchaKey] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val ignoreCaptchaKey = Extras.Key(false)
|
|
||||||
|
|
||||||
private const val CHANNEL_ID = "captcha"
|
|
||||||
private const val TAG = CHANNEL_ID
|
|
||||||
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
|
||||||
private const val SETTINGS_ACTION_CODE = 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +1,70 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
package org.koitharu.kotatsu.browser.cloudflare
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.webkit.CookieManager
|
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.core.graphics.Insets
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.HttpUrl
|
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.BaseBrowserActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
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.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
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.CloudFlareHelper
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
|
class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
||||||
|
|
||||||
private var pendingResult = RESULT_CANCELED
|
private var pendingResult = RESULT_CANCELED
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var cookieJar: MutableCookieJar
|
lateinit var cookieJar: MutableCookieJar
|
||||||
|
|
||||||
private lateinit var cfClient: CloudFlareClient
|
@Inject
|
||||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
lateinit var captchaHandler: CaptchaHandler
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
private lateinit var cfClient: CloudFlareClient
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||||
return
|
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||||
}
|
|
||||||
supportActionBar?.run {
|
|
||||||
setDisplayHomeAsUpEnabled(true)
|
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
|
||||||
}
|
|
||||||
val url = intent?.dataString
|
val url = intent?.dataString
|
||||||
if (url.isNullOrEmpty()) {
|
if (url.isNullOrEmpty()) {
|
||||||
finishAfterTransition()
|
finishAfterTransition()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
cfClient = CloudFlareClient(cookieJar, this, adBlock, url)
|
||||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
|
||||||
viewBinding.webView.webViewClient = cfClient
|
viewBinding.webView.webViewClient = cfClient
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
lifecycleScope.launch {
|
||||||
onBackPressedDispatcher.addCallback(it)
|
try {
|
||||||
|
proxyProvider.applyWebViewConfig()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
onTitleChanged(getString(R.string.loading_), url)
|
||||||
|
viewBinding.webView.loadUrl(url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
|
||||||
if (savedInstanceState == null) {
|
|
||||||
onTitleChanged(getString(R.string.loading_), url)
|
|
||||||
viewBinding.webView.loadUrl(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
runCatching {
|
|
||||||
viewBinding.webView
|
|
||||||
}.onSuccess {
|
|
||||||
it.stopLoading()
|
|
||||||
it.destroy()
|
|
||||||
}
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
@@ -89,17 +72,6 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
return super.onCreateOptionsMenu(menu)
|
return super.onCreateOptionsMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.appbar.updatePadding(
|
|
||||||
top = insets.top,
|
|
||||||
)
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
bottom = insets.bottom,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||||
android.R.id.home -> {
|
android.R.id.home -> {
|
||||||
viewBinding.webView.stopLoading()
|
viewBinding.webView.stopLoading()
|
||||||
@@ -115,21 +87,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
viewBinding.webView.onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
viewBinding.webView.onPause()
|
|
||||||
super.onPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finish() {
|
override fun finish() {
|
||||||
setResult(pendingResult)
|
setResult(pendingResult)
|
||||||
super.finish()
|
super.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||||
|
|
||||||
override fun onPageLoaded() {
|
override fun onPageLoaded() {
|
||||||
viewBinding.progressBar.isInvisible = true
|
viewBinding.progressBar.isInvisible = true
|
||||||
}
|
}
|
||||||
@@ -140,25 +104,22 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
|
|
||||||
override fun onCheckPassed() {
|
override fun onCheckPassed() {
|
||||||
pendingResult = RESULT_OK
|
pendingResult = RESULT_OK
|
||||||
val source = intent?.getStringExtra(ARG_SOURCE)
|
lifecycleScope.launch {
|
||||||
if (source != null) {
|
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
|
||||||
CaptchaNotifier(this).dismiss(MangaSource(source))
|
if (source != null) {
|
||||||
|
runCatchingCancellable {
|
||||||
|
captchaHandler.discard(MangaSource(source))
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTraceDebug()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishAfterTransition()
|
||||||
}
|
}
|
||||||
finishAfterTransition()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
|
||||||
viewBinding.progressBar.isVisible = isLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onHistoryChanged() {
|
|
||||||
onBackPressedCallback?.onHistoryChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||||
setTitle(title)
|
setTitle(title)
|
||||||
supportActionBar?.subtitle =
|
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle }
|
||||||
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restartCheck() {
|
private fun restartCheck() {
|
||||||
@@ -182,38 +143,16 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
|
|
||||||
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
|
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
|
||||||
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
||||||
return newIntent(context, input)
|
return AppRouter.cloudFlareResolveIntent(context, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||||
return resultCode == Activity.RESULT_OK
|
return resultCode == RESULT_OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val TAG = "CloudFlareActivity"
|
const val TAG = "CloudFlareActivity"
|
||||||
private const val ARG_UA = "ua"
|
|
||||||
private const val ARG_SOURCE = "_source"
|
|
||||||
|
|
||||||
fun newIntent(context: Context, exception: CloudFlareProtectedException) = newIntent(
|
|
||||||
context = context,
|
|
||||||
url = exception.url,
|
|
||||||
source = exception.source,
|
|
||||||
headers = exception.headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun newIntent(
|
|
||||||
context: Context,
|
|
||||||
url: String,
|
|
||||||
source: MangaSource?,
|
|
||||||
headers: Headers?,
|
|
||||||
) = Intent(context, CloudFlareActivity::class.java).apply {
|
|
||||||
data = url.toUri()
|
|
||||||
putExtra(ARG_SOURCE, source?.name)
|
|
||||||
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
|
||||||
putExtra(ARG_UA, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import org.koitharu.kotatsu.browser.BrowserCallback
|
|||||||
|
|
||||||
interface CloudFlareCallback : BrowserCallback {
|
interface CloudFlareCallback : BrowserCallback {
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
|
||||||
|
|
||||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
|
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
|
||||||
|
|
||||||
fun onPageLoaded()
|
fun onPageLoaded()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.graphics.Bitmap
|
|||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import org.koitharu.kotatsu.browser.BrowserClient
|
import org.koitharu.kotatsu.browser.BrowserClient
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
|
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
||||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
|
||||||
private const val LOOP_COUNTER = 3
|
private const val LOOP_COUNTER = 3
|
||||||
@@ -11,8 +12,9 @@ private const val LOOP_COUNTER = 3
|
|||||||
class CloudFlareClient(
|
class CloudFlareClient(
|
||||||
private val cookieJar: MutableCookieJar,
|
private val cookieJar: MutableCookieJar,
|
||||||
private val callback: CloudFlareCallback,
|
private val callback: CloudFlareCallback,
|
||||||
|
adBlock: AdBlock,
|
||||||
private val targetUrl: String,
|
private val targetUrl: String,
|
||||||
) : BrowserClient(callback) {
|
) : BrowserClient(callback, adBlock) {
|
||||||
|
|
||||||
private val oldClearance = getClearance()
|
private val oldClearance = getClearance()
|
||||||
private var counter = 0
|
private var counter = 0
|
||||||
@@ -22,7 +24,7 @@ class CloudFlareClient(
|
|||||||
checkClearance()
|
checkClearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
override fun onPageCommitVisible(view: WebView, url: String) {
|
||||||
super.onPageCommitVisible(view, url)
|
super.onPageCommitVisible(view, url)
|
||||||
callback.onPageLoaded()
|
callback.onPageLoaded()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.os.Build
|
|||||||
import android.provider.SearchRecentSuggestions
|
import android.provider.SearchRecentSuggestions
|
||||||
import android.text.Html
|
import android.text.Html
|
||||||
import androidx.collection.arraySetOf
|
import androidx.collection.arraySetOf
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.room.InvalidationTracker
|
import androidx.room.InvalidationTracker
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
@@ -30,8 +31,9 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
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.backups.domain.BackupObserver
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
|
||||||
import org.koitharu.kotatsu.core.image.AvifImageDecoder
|
import org.koitharu.kotatsu.core.image.AvifImageDecoder
|
||||||
import org.koitharu.kotatsu.core.image.CbzFetcher
|
import org.koitharu.kotatsu.core.image.CbzFetcher
|
||||||
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
|
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
|
||||||
@@ -40,25 +42,27 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
|||||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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.FileSize
|
||||||
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.MangaPageFetcher
|
||||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
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.FaviconCache
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalStorageCache
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
|
import org.koitharu.kotatsu.local.data.PageCache
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
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.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.Provider
|
||||||
@@ -76,6 +80,12 @@ interface AppModule {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@LocalizedAppContext
|
||||||
|
fun provideLocalizedContext(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): Context = ContextCompat.getContextForLanguage(context)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideNetworkState(
|
fun provideNetworkState(
|
||||||
@@ -92,13 +102,14 @@ interface AppModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideCoil(
|
fun provideCoil(
|
||||||
@ApplicationContext context: Context,
|
@LocalizedAppContext context: Context,
|
||||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
faviconFetcherFactory: FaviconFetcher.Factory,
|
||||||
imageProxyInterceptor: ImageProxyInterceptor,
|
imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||||
networkStateProvider: Provider<NetworkState>,
|
networkStateProvider: Provider<NetworkState>,
|
||||||
|
captchaHandler: CaptchaHandler,
|
||||||
): ImageLoader {
|
): ImageLoader {
|
||||||
val diskCacheFactory = {
|
val diskCacheFactory = {
|
||||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||||
@@ -114,7 +125,7 @@ interface AppModule {
|
|||||||
.diskCache(diskCacheFactory)
|
.diskCache(diskCacheFactory)
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
.allowRgb565(context.isLowRamDevice())
|
.allowRgb565(context.isLowRamDevice())
|
||||||
.eventListener(CaptchaNotifier(context))
|
.eventListener(captchaHandler)
|
||||||
.components {
|
.components {
|
||||||
add(
|
add(
|
||||||
OkHttpNetworkFetcherFactory(
|
OkHttpNetworkFetcherFactory(
|
||||||
@@ -130,7 +141,7 @@ interface AppModule {
|
|||||||
add(SvgDecoder.Factory())
|
add(SvgDecoder.Factory())
|
||||||
add(CbzFetcher.Factory())
|
add(CbzFetcher.Factory())
|
||||||
add(AvifImageDecoder.Factory())
|
add(AvifImageDecoder.Factory())
|
||||||
add(FaviconFetcher.Factory(mangaRepositoryFactory))
|
add(faviconFetcherFactory)
|
||||||
add(MangaPageKeyer())
|
add(MangaPageKeyer())
|
||||||
add(pageFetcherFactory)
|
add(pageFetcherFactory)
|
||||||
add(imageProxyInterceptor)
|
add(imageProxyInterceptor)
|
||||||
@@ -187,5 +198,29 @@ interface AppModule {
|
|||||||
fun provideWorkManager(
|
fun provideWorkManager(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): WorkManager = WorkManager.getInstance(context)
|
): WorkManager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@PageCache
|
||||||
|
fun providePageCache(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) = LocalStorageCache(
|
||||||
|
context = context,
|
||||||
|
dir = CacheDir.PAGES,
|
||||||
|
defaultSize = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
|
||||||
|
minSize = FileSize.MEGABYTES.convert(20, FileSize.BYTES),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@FaviconCache
|
||||||
|
fun provideFaviconCache(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) = LocalStorageCache(
|
||||||
|
context = context,
|
||||||
|
dir = CacheDir.FAVICONS,
|
||||||
|
defaultSize = FileSize.MEGABYTES.convert(8, FileSize.BYTES),
|
||||||
|
minSize = FileSize.MEGABYTES.convert(2, FileSize.BYTES),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import dagger.hilt.android.HiltAndroidApp
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import okhttp3.internal.platform.PlatformRegistry
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import org.acra.ReportField
|
import org.acra.ReportField
|
||||||
import org.acra.config.dialog
|
import org.acra.config.dialog
|
||||||
@@ -26,12 +26,14 @@ 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
|
||||||
import org.koitharu.kotatsu.core.os.AppValidator
|
import org.koitharu.kotatsu.core.os.AppValidator
|
||||||
|
import org.koitharu.kotatsu.core.os.RomCompat
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
|
||||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -78,21 +80,19 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
PlatformRegistry.applicationContext = this // TODO replace with OkHttp.initialize
|
||||||
if (ACRA.isACRASenderServiceProcess()) {
|
if (ACRA.isACRASenderServiceProcess()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
|
||||||
// TLS 1.3 support for Android < 10
|
// TLS 1.3 support for Android < 10
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||||
}
|
}
|
||||||
setupActivityLifecycleCallbacks()
|
setupActivityLifecycleCallbacks()
|
||||||
processLifecycleScope.launch {
|
processLifecycleScope.launch {
|
||||||
val isOriginalApp = withContext(Dispatchers.Default) {
|
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.getOrNull().toString())
|
||||||
appValidator.isOriginalApp
|
ACRA.errorReporter.putCustomData("isMiui", RomCompat.isMiui.getOrNull().toString())
|
||||||
}
|
|
||||||
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
|
|
||||||
}
|
}
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
@@ -104,6 +104,9 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
override fun attachBaseContext(base: Context) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
|
if (ACRA.isACRASenderServiceProcess()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
initAcra {
|
initAcra {
|
||||||
buildConfigClass = BuildConfig::class.java
|
buildConfigClass = BuildConfig::class.java
|
||||||
reportFormat = StringFormat.JSON
|
reportFormat = StringFormat.JSON
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import android.app.PendingIntent
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import androidx.core.app.NotificationCompat
|
||||||
import android.os.BadParcelableException
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.report
|
import org.koitharu.kotatsu.core.util.ext.report
|
||||||
@@ -15,24 +18,63 @@ import org.koitharu.kotatsu.core.util.ext.report
|
|||||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
class ErrorReporterReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
val e = intent?.getSerializableExtraCompat<Throwable>(EXTRA_ERROR) ?: return
|
val e = intent?.getSerializableExtraCompat<Throwable>(AppRouter.KEY_ERROR) ?: return
|
||||||
|
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
|
||||||
|
if (notificationId != 0 && context != null) {
|
||||||
|
val notificationTag = intent.getStringExtra(EXTRA_NOTIFICATION_TAG)
|
||||||
|
NotificationManagerCompat.from(context).cancel(notificationTag, notificationId)
|
||||||
|
}
|
||||||
e.report()
|
e.report()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val EXTRA_ERROR = "err"
|
|
||||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
||||||
|
private const val EXTRA_NOTIFICATION_ID = "notify.id"
|
||||||
|
private const val EXTRA_NOTIFICATION_TAG = "notify.tag"
|
||||||
|
|
||||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = getPendingIntentInternal(
|
||||||
|
context = context,
|
||||||
|
e = e,
|
||||||
|
notificationId = 0,
|
||||||
|
notificationTag = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getNotificationAction(
|
||||||
|
context: Context,
|
||||||
|
e: Throwable,
|
||||||
|
notificationId: Int,
|
||||||
|
notificationTag: String?,
|
||||||
|
): NotificationCompat.Action? {
|
||||||
|
val intent = getPendingIntentInternal(
|
||||||
|
context = context,
|
||||||
|
e = e,
|
||||||
|
notificationId = notificationId,
|
||||||
|
notificationTag = notificationTag,
|
||||||
|
) ?: return null
|
||||||
|
return NotificationCompat.Action(
|
||||||
|
R.drawable.ic_alert_outline,
|
||||||
|
context.getString(R.string.report),
|
||||||
|
intent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPendingIntentInternal(
|
||||||
|
context: Context,
|
||||||
|
e: Throwable,
|
||||||
|
notificationId: Int,
|
||||||
|
notificationTag: String?,
|
||||||
|
): PendingIntent? = runCatching {
|
||||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||||
intent.setAction(ACTION_REPORT)
|
intent.setAction(ACTION_REPORT)
|
||||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
intent.setData("err://${e.hashCode()}".toUri())
|
||||||
intent.putExtra(EXTRA_ERROR, e)
|
intent.putExtra(AppRouter.KEY_ERROR, e)
|
||||||
|
intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||||
|
intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
|
||||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||||
} catch (e: BadParcelableException) {
|
}.onFailure { e ->
|
||||||
|
// probably cannot write exception as serializable
|
||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
null
|
}.getOrNull()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Target(
|
||||||
|
AnnotationTarget.FUNCTION,
|
||||||
|
AnnotationTarget.PROPERTY_GETTER,
|
||||||
|
AnnotationTarget.PROPERTY_SETTER,
|
||||||
|
AnnotationTarget.VALUE_PARAMETER,
|
||||||
|
AnnotationTarget.FIELD,
|
||||||
|
)
|
||||||
|
annotation class LocalizedAppContext
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONArray
|
|
||||||
|
|
||||||
class BackupEntry(
|
|
||||||
val name: Name,
|
|
||||||
val data: JSONArray
|
|
||||||
) {
|
|
||||||
|
|
||||||
enum class Name(
|
|
||||||
val key: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
INDEX("index"),
|
|
||||||
HISTORY("history"),
|
|
||||||
CATEGORIES("categories"),
|
|
||||||
FAVOURITES("favourites"),
|
|
||||||
SETTINGS("settings"),
|
|
||||||
BOOKMARKS("bookmarks"),
|
|
||||||
SOURCES("sources"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import androidx.room.withTransaction
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import java.util.Date
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private const val PAGE_SIZE = 10
|
|
||||||
|
|
||||||
class BackupRepository @Inject constructor(
|
|
||||||
private val db: MangaDatabase,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun dumpHistory(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
|
|
||||||
if (history.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += history.size
|
|
||||||
for (item in history) {
|
|
||||||
val manga = JsonSerializer(item.manga).toJson()
|
|
||||||
val tags = JSONArray()
|
|
||||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
manga.put("tags", tags)
|
|
||||||
val json = JsonSerializer(item.history).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpCategories(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
|
|
||||||
val categories = db.getFavouriteCategoriesDao().findAll()
|
|
||||||
for (item in categories) {
|
|
||||||
entry.data.put(JsonSerializer(item).toJson())
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpFavourites(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
|
|
||||||
if (favourites.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += favourites.size
|
|
||||||
for (item in favourites) {
|
|
||||||
val manga = JsonSerializer(item.manga).toJson()
|
|
||||||
val tags = JSONArray()
|
|
||||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
manga.put("tags", tags)
|
|
||||||
val json = JsonSerializer(item.favourite).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpBookmarks(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
|
||||||
val all = db.getBookmarksDao().findAll()
|
|
||||||
for ((m, b) in all) {
|
|
||||||
val json = JSONObject()
|
|
||||||
val manga = JsonSerializer(m.manga).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
val tags = JSONArray()
|
|
||||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
json.put("tags", tags)
|
|
||||||
val bookmarks = JSONArray()
|
|
||||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
|
||||||
json.put("bookmarks", bookmarks)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dumpSettings(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
|
|
||||||
val settingsDump = settings.getAllValues().toMutableMap()
|
|
||||||
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
|
|
||||||
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
|
|
||||||
settingsDump.remove(AppSettings.KEY_PROXY_LOGIN)
|
|
||||||
settingsDump.remove(AppSettings.KEY_INCOGNITO_MODE)
|
|
||||||
val json = JsonSerializer(settingsDump).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpSources(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
|
||||||
val all = db.getSourcesDao().findAll()
|
|
||||||
for (source in all) {
|
|
||||||
val json = JsonSerializer(source).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createIndex(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
|
|
||||||
val json = JSONObject()
|
|
||||||
json.put("app_id", BuildConfig.APPLICATION_ID)
|
|
||||||
json.put("app_version", BuildConfig.VERSION_CODE)
|
|
||||||
json.put("created_at", System.currentTimeMillis())
|
|
||||||
entry.data.put(json)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBackupDate(entry: BackupEntry?): Date? {
|
|
||||||
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
|
|
||||||
return if (timestamp == 0L) null else Date(timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val history = JsonDeserializer(item).toHistoryEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getHistoryDao().upsert(history)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.getFavouriteCategoriesDao().upsert(category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val favourite = JsonDeserializer(item).toFavouriteEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getFavouritesDao().upsert(favourite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = item.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val bookmarks = item.getJSONArray("bookmarks").mapJSON {
|
|
||||||
JsonDeserializer(it).toBookmarkEntity()
|
|
||||||
}
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getBookmarksDao().upsert(bookmarks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val source = JsonDeserializer(item).toMangaSourceEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.getSourcesDao().upsert(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
settings.upsertAll(JsonDeserializer(item).toMap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okhttp3.internal.closeQuietly
|
|
||||||
import okio.Closeable
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
|
||||||
import java.io.File
|
|
||||||
import java.util.EnumSet
|
|
||||||
import java.util.zip.ZipException
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
class BackupZipInput private constructor(val file: File) : Closeable {
|
|
||||||
|
|
||||||
private val zipFile = ZipFile(file)
|
|
||||||
|
|
||||||
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
|
||||||
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
|
|
||||||
val json = zipFile.getInputStream(entry).use {
|
|
||||||
JSONArray(it.bufferedReader().readText())
|
|
||||||
}
|
|
||||||
BackupEntry(name, json)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
|
|
||||||
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
|
|
||||||
BackupEntry.Name.entries.find { it.key == ze.name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
zipFile.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun closeAndDelete() {
|
|
||||||
closeQuietly()
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun from(file: File): BackupZipInput {
|
|
||||||
var res: BackupZipInput? = null
|
|
||||||
return try {
|
|
||||||
res = BackupZipInput(file)
|
|
||||||
if (res.zipFile.getEntry("index") == null) {
|
|
||||||
throw BadBackupFormatException(null)
|
|
||||||
}
|
|
||||||
res
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
res?.closeQuietly()
|
|
||||||
throw if (exception is ZipException) {
|
|
||||||
BadBackupFormatException(exception)
|
|
||||||
} else {
|
|
||||||
exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okio.Closeable
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
|
||||||
import java.io.File
|
|
||||||
import java.text.ParseException
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.zip.Deflater
|
|
||||||
|
|
||||||
class BackupZipOutput(val file: File) : Closeable {
|
|
||||||
|
|
||||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
|
||||||
|
|
||||||
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
|
|
||||||
output.put(entry.name.key, entry.data.toString(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
|
||||||
output.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
output.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val DIR_BACKUPS = "backups"
|
|
||||||
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
|
||||||
|
|
||||||
fun generateFileName(context: Context) = buildString {
|
|
||||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
|
||||||
append('_')
|
|
||||||
append(dateTimeFormat.format(Date()))
|
|
||||||
append(".bk.zip")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseBackupDateTime(fileName: String): Date? = try {
|
|
||||||
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
|
||||||
} catch (e: ParseException) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
|
||||||
val dir = context.run {
|
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
}
|
|
||||||
dir.mkdirs()
|
|
||||||
BackupZipOutput(File(dir, generateFileName(context)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
class CompositeResult {
|
|
||||||
|
|
||||||
private var successCount: Int = 0
|
|
||||||
private val errors = ArrayList<Throwable?>()
|
|
||||||
|
|
||||||
val size: Int
|
|
||||||
get() = successCount + errors.size
|
|
||||||
|
|
||||||
val failures: List<Throwable>
|
|
||||||
get() = errors.filterNotNull()
|
|
||||||
|
|
||||||
val isEmpty: Boolean
|
|
||||||
get() = errors.isEmpty() && successCount == 0
|
|
||||||
|
|
||||||
val isAllSuccess: Boolean
|
|
||||||
get() = errors.none { it != null }
|
|
||||||
|
|
||||||
val isAllFailed: Boolean
|
|
||||||
get() = successCount == 0 && errors.isNotEmpty()
|
|
||||||
|
|
||||||
operator fun plusAssign(result: Result<*>) {
|
|
||||||
when {
|
|
||||||
result.isSuccess -> successCount++
|
|
||||||
result.isFailure -> errors.add(result.exceptionOrNull())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun plusAssign(other: CompositeResult) {
|
|
||||||
this.successCount += other.successCount
|
|
||||||
this.errors += other.errors
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun plus(other: CompositeResult): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
result.successCount = this.successCount + other.successCount
|
|
||||||
result.errors.addAll(this.errors)
|
|
||||||
result.errors.addAll(other.errors)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
|
||||||
|
|
||||||
class JsonDeserializer(private val json: JSONObject) {
|
|
||||||
|
|
||||||
fun toFavouriteEntity() = FavouriteEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
categoryId = json.getLong("category_id"),
|
|
||||||
sortKey = json.getIntOrDefault("sort_key", 0),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMangaEntity() = MangaEntity(
|
|
||||||
id = json.getLong("id"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
altTitle = json.getStringOrNull("alt_title"),
|
|
||||||
url = json.getString("url"),
|
|
||||||
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
|
||||||
rating = json.getDouble("rating").toFloat(),
|
|
||||||
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
|
||||||
coverUrl = json.getString("cover_url"),
|
|
||||||
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
|
||||||
state = json.getStringOrNull("state"),
|
|
||||||
author = json.getStringOrNull("author"),
|
|
||||||
source = json.getString("source"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toTagEntity() = TagEntity(
|
|
||||||
id = json.getLong("id"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
key = json.getString("key"),
|
|
||||||
source = json.getString("source"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toHistoryEntity() = HistoryEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
updatedAt = json.getLong("updated_at"),
|
|
||||||
chapterId = json.getLong("chapter_id"),
|
|
||||||
page = json.getInt("page"),
|
|
||||||
scroll = json.getDouble("scroll").toFloat(),
|
|
||||||
percent = json.getFloatOrDefault("percent", -1f),
|
|
||||||
chaptersCount = json.getIntOrDefault("chapters", -1),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
|
|
||||||
categoryId = json.getInt("category_id"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
sortKey = json.getInt("sort_key"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
|
||||||
track = json.getBooleanOrDefault("track", true),
|
|
||||||
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toBookmarkEntity() = BookmarkEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
pageId = json.getLong("page_id"),
|
|
||||||
chapterId = json.getLong("chapter_id"),
|
|
||||||
page = json.getInt("page"),
|
|
||||||
scroll = json.getInt("scroll"),
|
|
||||||
imageUrl = json.getString("image_url"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
percent = json.getDouble("percent").toFloat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMangaSourceEntity() = MangaSourceEntity(
|
|
||||||
source = json.getString("source"),
|
|
||||||
isEnabled = json.getBoolean("enabled"),
|
|
||||||
sortKey = json.getInt("sort_key"),
|
|
||||||
addedIn = json.getIntOrDefault("added_in", 0),
|
|
||||||
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
|
||||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMap(): Map<String, Any?> {
|
|
||||||
val map = mutableMapOf<String, Any?>()
|
|
||||||
val keys = json.keys()
|
|
||||||
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
val value = json.get(key)
|
|
||||||
map[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
|
|
||||||
class JsonSerializer private constructor(private val json: JSONObject) {
|
|
||||||
|
|
||||||
constructor(e: FavouriteEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("category_id", e.categoryId)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: FavouriteCategoryEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("category_id", e.categoryId)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("title", e.title)
|
|
||||||
put("order", e.order)
|
|
||||||
put("track", e.track)
|
|
||||||
put("show_in_lib", e.isVisibleInLibrary)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: HistoryEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("updated_at", e.updatedAt)
|
|
||||||
put("chapter_id", e.chapterId)
|
|
||||||
put("page", e.page)
|
|
||||||
put("scroll", e.scroll)
|
|
||||||
put("percent", e.percent)
|
|
||||||
put("chapters", e.chaptersCount)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: TagEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("id", e.id)
|
|
||||||
put("title", e.title)
|
|
||||||
put("key", e.key)
|
|
||||||
put("source", e.source)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: MangaEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("id", e.id)
|
|
||||||
put("title", e.title)
|
|
||||||
put("alt_title", e.altTitle)
|
|
||||||
put("url", e.url)
|
|
||||||
put("public_url", e.publicUrl)
|
|
||||||
put("rating", e.rating)
|
|
||||||
put("nsfw", e.isNsfw)
|
|
||||||
put("cover_url", e.coverUrl)
|
|
||||||
put("large_cover_url", e.largeCoverUrl)
|
|
||||||
put("state", e.state)
|
|
||||||
put("author", e.author)
|
|
||||||
put("source", e.source)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: BookmarkEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("page_id", e.pageId)
|
|
||||||
put("chapter_id", e.chapterId)
|
|
||||||
put("page", e.page)
|
|
||||||
put("scroll", e.scroll)
|
|
||||||
put("image_url", e.imageUrl)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("percent", e.percent)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: MangaSourceEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("source", e.source)
|
|
||||||
put("enabled", e.isEnabled)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("added_in", e.addedIn)
|
|
||||||
put("used_at", e.lastUsedAt)
|
|
||||||
put("pinned", e.isPinned)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(m: Map<String, *>) : this(
|
|
||||||
JSONObject(m),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toJson(): JSONObject = json
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.cache
|
package org.koitharu.kotatsu.core.cache
|
||||||
|
|
||||||
import androidx.collection.LruCache
|
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
|
||||||
|
|
||||||
@@ -8,11 +9,9 @@ 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<CacheKey, ExpiringValue<T>>(maxSize)
|
private val cache = SynchronizedSieveCache<CacheKey, ExpiringValue<T>>(maxSize)
|
||||||
|
|
||||||
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
|
|
||||||
|
|
||||||
operator fun get(key: CacheKey): T? {
|
operator fun get(key: CacheKey): T? {
|
||||||
val value = cache[key] ?: return null
|
val value = cache[key] ?: return null
|
||||||
@@ -23,7 +22,8 @@ class ExpiringLruCache<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
operator fun set(key: CacheKey, value: T) {
|
operator fun set(key: CacheKey, value: T) {
|
||||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
val value = ExpiringValue(value, lifetime, timeUnit)
|
||||||
|
cache.put(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
@@ -37,4 +37,8 @@ class ExpiringLruCache<T>(
|
|||||||
fun remove(key: CacheKey) {
|
fun remove(key: CacheKey) {
|
||||||
cache.remove(key)
|
cache.remove(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun removeAll(source: MangaSource) {
|
||||||
|
cache.removeIf { key, _ -> key.source == source }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,11 +81,7 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
||||||
cache.forEach { key ->
|
cache.removeAll(source)
|
||||||
if (key.source == source) {
|
|
||||||
cache.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Key(
|
data class Key(
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
|
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
|
||||||
|
import org.koitharu.kotatsu.core.db.dao.ChaptersDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.MangaDao
|
import org.koitharu.kotatsu.core.db.dao.MangaDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
|
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
|
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.TagsDao
|
import org.koitharu.kotatsu.core.db.dao.TagsDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
|
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
@@ -36,6 +38,11 @@ import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration24To25
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration25To26
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration26To27
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||||
@@ -63,14 +70,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||||
|
|
||||||
const val DATABASE_VERSION = 23
|
const val DATABASE_VERSION = 27
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, ChapterEntity::class,
|
||||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class,
|
||||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class,
|
||||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION,
|
||||||
)
|
)
|
||||||
@@ -103,6 +110,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
abstract fun getStatsDao(): StatsDao
|
abstract fun getStatsDao(): StatsDao
|
||||||
|
|
||||||
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
||||||
|
|
||||||
|
abstract fun getChaptersDao(): ChaptersDao
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||||
@@ -128,6 +137,11 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration20To21(),
|
Migration20To21(),
|
||||||
Migration21To22(),
|
Migration21To22(),
|
||||||
Migration22To23(),
|
Migration22To23(),
|
||||||
|
Migration23To24(),
|
||||||
|
Migration24To23(),
|
||||||
|
Migration24To25(),
|
||||||
|
Migration25To26(),
|
||||||
|
Migration26To27(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
|
|||||||
const val TABLE_HISTORY = "history"
|
const val TABLE_HISTORY = "history"
|
||||||
const val TABLE_MANGA_TAGS = "manga_tags"
|
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||||
const val TABLE_SOURCES = "sources"
|
const val TABLE_SOURCES = "sources"
|
||||||
|
const val TABLE_CHAPTERS = "chapters"
|
||||||
|
const val TABLE_PREFERENCES = "preferences"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class ChaptersDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM chapters WHERE manga_id = :mangaId ORDER BY `index` ASC")
|
||||||
|
abstract suspend fun findAll(mangaId: Long): List<ChapterEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM chapters WHERE manga_id = :mangaId")
|
||||||
|
abstract suspend fun deleteAll(mangaId: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM chapters WHERE manga_id NOT IN (SELECT manga_id FROM history WHERE deleted_at = 0) AND manga_id NOT IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
|
||||||
|
abstract suspend fun gc()
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun replaceAll(mangaId: Long, entities: Collection<ChapterEntity>) {
|
||||||
|
deleteAll(mangaId)
|
||||||
|
insert(entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
protected abstract suspend fun insert(entities: Collection<ChapterEntity>)
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ abstract class MangaDao {
|
|||||||
@Query("SELECT * FROM manga WHERE manga_id = :id")
|
@Query("SELECT * FROM manga WHERE manga_id = :id")
|
||||||
abstract suspend fun find(id: Long): MangaWithTags?
|
abstract suspend fun find(id: Long): MangaWithTags?
|
||||||
|
|
||||||
|
@Query("SELECT EXISTS(SELECT * FROM manga WHERE manga_id = :id)")
|
||||||
|
abstract suspend operator fun contains(id: Long): Boolean
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
||||||
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
||||||
@@ -55,6 +58,19 @@ abstract class MangaDao {
|
|||||||
@Delete
|
@Delete
|
||||||
abstract suspend fun delete(subjects: Collection<MangaEntity>)
|
abstract suspend fun delete(subjects: Collection<MangaEntity>)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
DELETE FROM manga WHERE NOT EXISTS(SELECT * FROM history WHERE history.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM favourites WHERE favourites.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM bookmarks WHERE bookmarks.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM suggestions WHERE suggestions.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM scrobblings WHERE scrobblings.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM local_index WHERE local_index.manga_id == manga.manga_id)
|
||||||
|
AND manga.manga_id NOT IN (:idsToKeep)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
abstract suspend fun cleanup(idsToKeep: Set<Long>)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
||||||
upsert(manga)
|
upsert(manga)
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import androidx.room.Transaction
|
|||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.intellij.lang.annotations.Language
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper.PROTECTION_CAPTCHA
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class MangaSourcesDao {
|
abstract class MangaSourcesDao {
|
||||||
@@ -51,6 +55,9 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
||||||
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET cf_state = :state WHERE source = :source")
|
||||||
|
abstract suspend fun setCfState(source: String, state: Int)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
@Transaction
|
@Transaction
|
||||||
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
||||||
@@ -61,21 +68,14 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("SELECT * FROM sources WHERE pinned = 1")
|
@Query("SELECT * FROM sources WHERE pinned = 1")
|
||||||
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
||||||
|
|
||||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
@Query("SELECT * FROM sources WHERE cf_state = $PROTECTION_CAPTCHA")
|
||||||
val orderBy = getOrderBy(order)
|
abstract suspend fun findAllCaptchaRequired(): List<MangaSourceEntity>
|
||||||
|
|
||||||
@Language("RoomSql")
|
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
observeImpl(getQuery(enabledOnly, order))
|
||||||
return observeImpl(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
|
suspend fun findAll(enabledOnly: Boolean, order: SourcesSortOrder): List<MangaSourceEntity> =
|
||||||
val orderBy = getOrderBy(order)
|
findAllImpl(getQuery(enabledOnly, order))
|
||||||
|
|
||||||
@Language("RoomSql")
|
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
|
||||||
return findAllImpl(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
||||||
@@ -87,11 +87,25 @@ abstract class MangaSourcesDao {
|
|||||||
addedIn = BuildConfig.VERSION_CODE,
|
addedIn = BuildConfig.VERSION_CODE,
|
||||||
lastUsedAt = 0,
|
lastUsedAt = 0,
|
||||||
isPinned = false,
|
isPinned = false,
|
||||||
|
cfState = CloudFlareHelper.PROTECTION_NOT_DETECTED,
|
||||||
)
|
)
|
||||||
upsert(entity)
|
upsert(entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dumpEnabled(): Flow<MangaSourceEntity> = flow {
|
||||||
|
val window = 10
|
||||||
|
var offset = 0
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val list = findAllEnabled(offset, window)
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += window
|
||||||
|
list.forEach { emit(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
|
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
|
||||||
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
|
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
|
||||||
|
|
||||||
@@ -101,6 +115,20 @@ abstract class MangaSourcesDao {
|
|||||||
@RawQuery
|
@RawQuery
|
||||||
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
|
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY source LIMIT :limit OFFSET :offset")
|
||||||
|
protected abstract suspend fun findAllEnabled(offset: Int, limit: Int): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery(
|
||||||
|
buildString {
|
||||||
|
append("SELECT * FROM sources ")
|
||||||
|
if (enabledOnly) {
|
||||||
|
append("WHERE enabled = 1 ")
|
||||||
|
}
|
||||||
|
append("ORDER BY pinned DESC, ")
|
||||||
|
append(getOrderBy(order))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
|
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
|
||||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user