Compare commits
1058 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881f154b5e | ||
|
|
34be5d16f2 | ||
|
|
e7e554648d | ||
|
|
89a4180b46 | ||
|
|
4e2e190547 | ||
|
|
3c557aae6c | ||
|
|
0b00a3675d | ||
|
|
8f20be6953 | ||
|
|
26875c01c6 | ||
|
|
4beb34c1a5 | ||
|
|
1d50ab00c4 | ||
|
|
299cd229ec | ||
|
|
b02f394cd4 | ||
|
|
7352f06564 | ||
|
|
1e4861367e | ||
|
|
bc3208946b | ||
|
|
d5fbb00676 | ||
|
|
7514362ca4 | ||
|
|
e76a04bea0 | ||
|
|
732a6e7c26 | ||
|
|
f3111dc636 | ||
|
|
e0e0cf4ecd | ||
|
|
50f302a7f8 | ||
|
|
500995a9d8 | ||
|
|
beaf5cc0d5 | ||
|
|
6377de470d | ||
|
|
dec45f7851 | ||
|
|
dbada34a43 | ||
|
|
b62467964e | ||
|
|
3249e10931 | ||
|
|
0d5229b112 | ||
|
|
d0ed1fb85f | ||
|
|
9e5664da3a | ||
|
|
35c158d35a | ||
|
|
464f24e9f0 | ||
|
|
c8a8203c39 | ||
|
|
b414758f32 | ||
|
|
1181860e41 | ||
|
|
e35521f16f | ||
|
|
5fb8ff53f9 | ||
|
|
a66283d035 | ||
|
|
a1ba0b8c21 | ||
|
|
f3b42b9a42 | ||
|
|
aa2f2c17fc | ||
|
|
ebc17b645b | ||
|
|
cc14e1abcf | ||
|
|
b1b474e2e7 | ||
|
|
8ca3bece5d | ||
|
|
90bd9023d5 | ||
|
|
986627f24d | ||
|
|
cf2b8e2481 | ||
|
|
b9435de5cd | ||
|
|
861c21faea | ||
|
|
9b4d014b21 | ||
|
|
c6da7de699 | ||
|
|
ef3aa40acc | ||
|
|
07af3ea703 | ||
|
|
391c8ab649 | ||
|
|
6b1885c89d | ||
|
|
8423b48fb9 | ||
|
|
803c825d91 | ||
|
|
6a9682a077 | ||
|
|
9197b9cc3a | ||
|
|
02ea804874 | ||
|
|
c424466198 | ||
|
|
18b312dde6 | ||
|
|
f78262b1a0 | ||
|
|
c557a51c4d | ||
|
|
8995762935 | ||
|
|
ed2664db78 | ||
|
|
f5a5e53b5a | ||
|
|
9ef961590d | ||
|
|
9b569615ee | ||
|
|
f48cf2efe4 | ||
|
|
18094a310c | ||
|
|
320c49a831 | ||
|
|
2a971d5dae | ||
|
|
4467e79ae6 | ||
|
|
c68b180bf6 | ||
|
|
5f879f6c83 | ||
|
|
aeb3732d75 | ||
|
|
6292a0fd6b | ||
|
|
8985b4135d | ||
|
|
f8a5397542 | ||
|
|
5f51041220 | ||
|
|
5a14412b62 | ||
|
|
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 | ||
|
|
1d1e49123a | ||
|
|
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 | ||
|
|
0b8afe9c40 | ||
|
|
734846a018 | ||
|
|
754ccc4197 | ||
|
|
ef691b1aed | ||
|
|
e75035b33a | ||
|
|
f675c606a2 | ||
|
|
a5199e2f06 | ||
|
|
1b80e48ed4 | ||
|
|
07e81f21c7 | ||
|
|
0dbd01f6fc | ||
|
|
4b453b58dd | ||
|
|
1575bb5242 | ||
|
|
55137cf899 | ||
|
|
f190ff810e | ||
|
|
47c13b46f7 | ||
|
|
2ad9f38906 | ||
|
|
2783c62ace | ||
|
|
c1a65f8055 | ||
|
|
6e5d8e99ca | ||
|
|
020c3b8bba | ||
|
|
76162a06e3 | ||
|
|
19f398d309 | ||
|
|
1bd916371a | ||
|
|
25ae23963e | ||
|
|
146ba95af6 | ||
|
|
cd40dab8a4 | ||
|
|
ee10b013a1 | ||
|
|
8c79df3d35 | ||
|
|
2c2db1ca96 | ||
|
|
f556c0b127 | ||
|
|
d2ed8a1ace | ||
|
|
024e3c11ee | ||
|
|
23ba302df8 | ||
|
|
34e54e43e0 | ||
|
|
07a8de6225 | ||
|
|
a3df6f799c | ||
|
|
d5722790ef | ||
|
|
8bf540abbe | ||
|
|
5241fa0d13 | ||
|
|
87e0c931a2 | ||
|
|
a51412801a | ||
|
|
a6c188d647 | ||
|
|
831632cb8f | ||
|
|
ad59bf50f4 | ||
|
|
6fe6c05327 | ||
|
|
66645d93f8 | ||
|
|
f2582bce1d | ||
|
|
b5053b7820 | ||
|
|
e4df81495d | ||
|
|
295c5bed9f | ||
|
|
5fd1cbadcd | ||
|
|
9dd86f57e6 | ||
|
|
bce6d71743 | ||
|
|
6367c06f49 | ||
|
|
3aa8e9d6d3 | ||
|
|
ac2b367312 | ||
|
|
5cd9b02159 | ||
|
|
0bd62c6925 | ||
|
|
d657216a69 | ||
|
|
39f91464dc | ||
|
|
05422b95a1 | ||
|
|
554e3c1b61 | ||
|
|
56ece80f2a | ||
|
|
3ebde0284d | ||
|
|
c993488fe7 | ||
|
|
e65a3b43f6 | ||
|
|
f11a9d8235 | ||
|
|
8a4bd9a19a | ||
|
|
cffc6cfd39 | ||
|
|
1568a48328 | ||
|
|
0b47b113e0 | ||
|
|
67a5ef016c | ||
|
|
09c049ea9d | ||
|
|
0dc1cad52b | ||
|
|
782ea0541e | ||
|
|
b220703dd4 | ||
|
|
c5b6586cf4 | ||
|
|
1ba40ea248 | ||
|
|
e8fd2b0dcf | ||
|
|
046b7b6ef1 | ||
|
|
907856a0df | ||
|
|
071509ecd1 | ||
|
|
a0cb34b984 | ||
|
|
7fe8217f6d | ||
|
|
58937f9fc6 | ||
|
|
528b85e9ce | ||
|
|
b57fdd5a99 | ||
|
|
1ad29cebd7 | ||
|
|
7516303b7d | ||
|
|
b2bfebaea2 | ||
|
|
9fcff1eac7 | ||
|
|
19446db192 | ||
|
|
609f2bd134 | ||
|
|
3ef7c6adb0 | ||
|
|
62e7e5d8c3 | ||
|
|
644f0af262 | ||
|
|
a1e5d78877 | ||
|
|
635839065d | ||
|
|
bb6f7b1e9f | ||
|
|
30e43d3bfe | ||
|
|
1f0180d601 | ||
|
|
cdce2af4a3 | ||
|
|
11212ed071 | ||
|
|
e2902fa1ba | ||
|
|
5158f2a70a | ||
|
|
f9e4752b8c | ||
|
|
901ffebf97 | ||
|
|
dba727bfcb | ||
|
|
3ee97a3b99 | ||
|
|
57d1f54318 | ||
|
|
02073f6d45 | ||
|
|
b66a77843e | ||
|
|
03518dd9b4 | ||
|
|
d926f334e8 | ||
|
|
e2f8d8e022 | ||
|
|
38b342b721 | ||
|
|
b036a8ed94 | ||
|
|
e4fda86bf1 | ||
|
|
6e20cee972 | ||
|
|
8901d02dba | ||
|
|
a87b37ce1c | ||
|
|
4f22e29ad6 | ||
|
|
6effb928fd | ||
|
|
1b1d0014da | ||
|
|
a9632f542b | ||
|
|
a2c256d47f | ||
|
|
f87a75e61e | ||
|
|
09354ae31f | ||
|
|
fb25b8fb3a | ||
|
|
c8b935ccc3 | ||
|
|
7f0376d792 | ||
|
|
0c56e730fe | ||
|
|
a7138d23ac | ||
|
|
a0de73a7ed | ||
|
|
90f0846fb4 | ||
|
|
9425d29596 | ||
|
|
9bb76cc0b2 | ||
|
|
ad0452486f | ||
|
|
855b55da9d | ||
|
|
436168b940 | ||
|
|
681c80dc3e | ||
|
|
4855b2c160 | ||
|
|
89d395178c | ||
|
|
9942ad5e56 | ||
|
|
d59b0626bc | ||
|
|
63054e55d6 | ||
|
|
486daf69bf | ||
|
|
af209d7048 | ||
|
|
c15a0ece3e | ||
|
|
6bf034fd37 | ||
|
|
5bccc595a8 | ||
|
|
9559e148c6 | ||
|
|
637a040a0b | ||
|
|
2bdf146548 | ||
|
|
22831a9796 | ||
|
|
b5bc64c89f | ||
|
|
f2ad58bc97 | ||
|
|
835a1c73b6 | ||
|
|
5b8a628715 | ||
|
|
4f5418e074 | ||
|
|
1cf56b2303 | ||
|
|
a47dcd9ec2 | ||
|
|
7873cc4099 | ||
|
|
9002915e30 | ||
|
|
099d9df84c | ||
|
|
e531e6bcb8 | ||
|
|
77ed44bb08 | ||
|
|
1b0b495029 | ||
|
|
b6296fd586 | ||
|
|
985b062218 | ||
|
|
b6f57e5656 | ||
|
|
3d285104a4 | ||
|
|
100073f45e | ||
|
|
c1d577bdf3 | ||
|
|
2214c20742 | ||
|
|
688a9fe4d5 | ||
|
|
af5df32fbe | ||
|
|
d739e30c84 | ||
|
|
32eb273fa9 | ||
|
|
8c5231bb3d | ||
|
|
be4fb3e873 | ||
|
|
d28eff7a75 | ||
|
|
b81063910b | ||
|
|
702ee70f70 | ||
|
|
c5bd979645 | ||
|
|
e515069b53 | ||
|
|
3255fba3c4 | ||
|
|
144e66bedb | ||
|
|
05d22167c4 | ||
|
|
e5c765dd2f | ||
|
|
557b69d73f | ||
|
|
1e22e8de45 | ||
|
|
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 |
@@ -4,7 +4,7 @@ root = true
|
|||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
indent_style = tab
|
indent_style = space
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 120
|
max_line_length = 120
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|||||||
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
@@ -6,6 +6,7 @@
|
|||||||
/.idea/dictionaries
|
/.idea/dictionaries
|
||||||
/.idea/modules.xml
|
/.idea/modules.xml
|
||||||
/.idea/misc.xml
|
/.idea/misc.xml
|
||||||
|
/.idea/markdown.xml
|
||||||
/.idea/discord.xml
|
/.idea/discord.xml
|
||||||
/.idea/compiler.xml
|
/.idea/compiler.xml
|
||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
@@ -26,3 +27,4 @@
|
|||||||
.cxx
|
.cxx
|
||||||
/.idea/deviceManager.xml
|
/.idea/deviceManager.xml
|
||||||
/.kotlin/
|
/.kotlin/
|
||||||
|
/.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>
|
||||||
74
.idea/codeStyles/Project.xml
generated
74
.idea/codeStyles/Project.xml
generated
@@ -1,9 +1,7 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
<option name="OTHER_INDENT_OPTIONS">
|
<option name="OTHER_INDENT_OPTIONS">
|
||||||
<value>
|
<value />
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</value>
|
|
||||||
</option>
|
</option>
|
||||||
<AndroidXmlCodeStyleSettings>
|
<AndroidXmlCodeStyleSettings>
|
||||||
<option name="LAYOUT_SETTINGS">
|
<option name="LAYOUT_SETTINGS">
|
||||||
@@ -22,40 +20,46 @@
|
|||||||
</value>
|
</value>
|
||||||
</option>
|
</option>
|
||||||
</AndroidXmlCodeStyleSettings>
|
</AndroidXmlCodeStyleSettings>
|
||||||
|
<JavaCodeStyleSettings>
|
||||||
|
<option name="IMPORT_LAYOUT_TABLE">
|
||||||
|
<value>
|
||||||
|
<package name="android" withSubpackages="true" static="true" />
|
||||||
|
<package name="androidx" withSubpackages="true" static="true" />
|
||||||
|
<package name="com" withSubpackages="true" static="true" />
|
||||||
|
<package name="junit" withSubpackages="true" static="true" />
|
||||||
|
<package name="net" withSubpackages="true" static="true" />
|
||||||
|
<package name="org" withSubpackages="true" static="true" />
|
||||||
|
<package name="java" withSubpackages="true" static="true" />
|
||||||
|
<package name="javax" withSubpackages="true" static="true" />
|
||||||
|
<package name="" withSubpackages="true" static="true" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="android" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="androidx" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="com" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="junit" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="net" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="org" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="java" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="javax" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</JavaCodeStyleSettings>
|
||||||
<JetCodeStyleSettings>
|
<JetCodeStyleSettings>
|
||||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||||
|
<option name="ALLOW_TRAILING_COMMA_COLLECTION_LITERAL_EXPRESSION" value="true" />
|
||||||
|
<option name="ALLOW_TRAILING_COMMA_VALUE_ARGUMENT_LIST" value="true" />
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
</JetCodeStyleSettings>
|
</JetCodeStyleSettings>
|
||||||
<codeStyleSettings language="CMake">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="Groovy">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="HTML">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="JAVA">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="JSON">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="ObjectiveC">
|
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="Shell Script">
|
<codeStyleSettings language="Shell Script">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
@@ -64,7 +68,6 @@
|
|||||||
<codeStyleSettings language="XML">
|
<codeStyleSettings language="XML">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
<arrangement>
|
<arrangement>
|
||||||
<rules>
|
<rules>
|
||||||
@@ -179,9 +182,6 @@
|
|||||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
||||||
<indentOptions>
|
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
||||||
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -13,8 +13,7 @@
|
|||||||
<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>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
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>
|
||||||
117
README.md
117
README.md
@@ -1,56 +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.
|
|
||||||
|
* **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 6.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 and does not store or distribute any content. This application should be considered a web browser, all content that can be found using this application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website where the content is hosted.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
202
app/build.gradle
202
app/build.gradle
@@ -1,32 +1,43 @@
|
|||||||
|
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 = 676
|
versionCode = 1032
|
||||||
versionName = '7.6.3'
|
versionName = '9.4'
|
||||||
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 {
|
||||||
@@ -37,11 +48,23 @@ android {
|
|||||||
shrinkResources true
|
shrinkResources true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
|
nightly {
|
||||||
|
initWith release
|
||||||
|
applicationIdSuffix = '.nightly'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
buildConfig true
|
buildConfig true
|
||||||
}
|
}
|
||||||
|
packagingOptions {
|
||||||
|
resources {
|
||||||
|
excludes += [
|
||||||
|
'META-INF/README.md',
|
||||||
|
'META-INF/NOTICE.md'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
main.java.srcDirs += 'src/main/kotlin/'
|
main.java.srcDirs += 'src/main/kotlin/'
|
||||||
@@ -57,14 +80,23 @@ 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=coil.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'
|
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.includeAndroidResources true
|
unitTests.includeAndroidResources true
|
||||||
@@ -73,95 +105,107 @@ android {
|
|||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
applicationVariants.configureEach { variant ->
|
||||||
afterEvaluate {
|
if (variant.name == 'nightly') {
|
||||||
compileDebugKotlin {
|
variant.outputs.each { output ->
|
||||||
kotlinOptions {
|
def now = LocalDateTime.now()
|
||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
output.versionCodeOverride = now.format("yyMMdd").toInteger()
|
||||||
|
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:1ebb298cd7') {
|
def parsersVersion = libs.versions.parsers.get()
|
||||||
|
if (System.properties.containsKey('parsersVersionOverride')) {
|
||||||
|
// usage:
|
||||||
|
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
|
||||||
|
parsersVersion = System.getProperty('parsersVersionOverride')
|
||||||
|
}
|
||||||
|
//noinspection UseTomlInstead
|
||||||
|
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
coreLibraryDesugaring libs.desugar.jdk.libs
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20'
|
implementation libs.kotlin.stdlib
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
implementation libs.kotlinx.coroutines.android
|
||||||
|
implementation libs.kotlinx.coroutines.guava
|
||||||
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
implementation libs.androidx.appcompat
|
||||||
implementation 'androidx.core:core-ktx:1.13.1'
|
implementation libs.androidx.core
|
||||||
implementation 'androidx.activity:activity-ktx:1.9.2'
|
implementation libs.androidx.activity
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.8.4'
|
implementation libs.androidx.fragment
|
||||||
implementation 'androidx.transition:transition-ktx:1.5.1'
|
implementation libs.androidx.transition
|
||||||
implementation 'androidx.collection:collection-ktx:1.4.4'
|
implementation libs.androidx.collection
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
implementation libs.lifecycle.viewmodel
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
|
implementation libs.lifecycle.service
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
|
implementation libs.lifecycle.process
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation libs.androidx.constraintlayout
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation libs.androidx.documentfile
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
implementation libs.androidx.swiperefreshlayout
|
||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0'
|
implementation libs.androidx.recyclerview
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
implementation libs.androidx.viewpager2
|
||||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
implementation libs.androidx.preference
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation libs.androidx.biometric
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
|
implementation libs.material
|
||||||
implementation 'androidx.webkit:webkit:1.11.0'
|
implementation libs.androidx.lifecycle.common.java8
|
||||||
|
implementation libs.androidx.webkit
|
||||||
|
|
||||||
implementation 'androidx.work:work-runtime:2.9.1'
|
implementation libs.androidx.work.runtime
|
||||||
//noinspection GradleDependency
|
implementation libs.guava
|
||||||
implementation('com.google.guava:guava:33.2.1-android') {
|
|
||||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
|
||||||
exclude group: 'org.checkerframework', module: 'checker-qual'
|
|
||||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
|
||||||
}
|
|
||||||
|
|
||||||
implementation 'androidx.room:room-runtime:2.6.1'
|
implementation libs.androidx.room.runtime
|
||||||
implementation 'androidx.room:room-ktx:2.6.1'
|
implementation libs.androidx.room.ktx
|
||||||
ksp 'androidx.room:room-compiler:2.6.1'
|
ksp libs.androidx.room.compiler
|
||||||
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
implementation libs.okhttp
|
||||||
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
implementation libs.okhttp.tls
|
||||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
implementation libs.okhttp.dnsoverhttps
|
||||||
implementation 'com.squareup.okio:okio:3.9.1'
|
implementation libs.okio
|
||||||
|
implementation libs.kotlinx.serialization.json
|
||||||
|
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
implementation libs.adapterdelegates
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
implementation libs.adapterdelegates.viewbinding
|
||||||
|
|
||||||
implementation 'com.google.dagger:hilt-android:2.52'
|
implementation libs.hilt.android
|
||||||
kapt 'com.google.dagger:hilt-compiler:2.52'
|
ksp libs.hilt.compiler
|
||||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
implementation libs.androidx.hilt.work
|
||||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
ksp libs.androidx.hilt.compiler
|
||||||
|
|
||||||
implementation 'io.coil-kt:coil-base:2.7.0'
|
implementation libs.coil.core
|
||||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
implementation libs.coil.network
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:e04098de68'
|
implementation libs.coil.gif
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation libs.coil.svg
|
||||||
implementation 'io.noties.markwon:core:4.6.2'
|
implementation libs.avif.decoder
|
||||||
|
implementation libs.ssiv
|
||||||
|
implementation libs.disk.lru.cache
|
||||||
|
implementation libs.markwon
|
||||||
|
implementation libs.kizzyrpc
|
||||||
|
|
||||||
implementation 'ch.acra:acra-http:5.11.4'
|
implementation libs.acra.http
|
||||||
implementation 'ch.acra:acra-dialog:5.11.4'
|
implementation libs.acra.dialog
|
||||||
|
|
||||||
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
implementation libs.conscrypt.android
|
||||||
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8'
|
debugImplementation libs.leakcanary.android
|
||||||
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
nightlyImplementation libs.leakcanary.android
|
||||||
|
debugImplementation libs.workinspector
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation libs.junit
|
||||||
testImplementation 'org.json:json:20240303'
|
testImplementation libs.json
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
testImplementation libs.kotlinx.coroutines.test
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.6.1'
|
androidTestImplementation libs.androidx.runner
|
||||||
androidTestImplementation 'androidx.test:rules:1.6.1'
|
androidTestImplementation libs.androidx.rules
|
||||||
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
androidTestImplementation libs.androidx.test.core
|
||||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
androidTestImplementation libs.androidx.junit
|
||||||
|
|
||||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
androidTestImplementation libs.kotlinx.coroutines.test
|
||||||
|
|
||||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
androidTestImplementation libs.androidx.room.testing
|
||||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
androidTestImplementation libs.moshi.kotlin
|
||||||
|
|
||||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52'
|
androidTestImplementation libs.hilt.android.testing
|
||||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52'
|
kspAndroidTest libs.hilt.android.compiler
|
||||||
}
|
}
|
||||||
|
|||||||
11
app/proguard-rules.pro
vendored
11
app/proguard-rules.pro
vendored
@@ -8,21 +8,24 @@
|
|||||||
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.**
|
||||||
-dontwarn org.openjsse.**
|
-dontwarn org.openjsse.**
|
||||||
-dontwarn com.google.j2objc.annotations.**
|
-dontwarn com.google.j2objc.annotations.**
|
||||||
|
-dontwarn coil3.PlatformContext
|
||||||
|
|
||||||
|
-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.exceptions.* { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
|
||||||
-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
|
||||||
|
|
||||||
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
||||||
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
||||||
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
||||||
|
-keep class org.acra.sender.JobSenderService
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import android.os.Build
|
|||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import android.os.strictmode.Violation
|
import android.os.strictmode.Violation
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.asExecutor
|
import kotlinx.coroutines.asExecutor
|
||||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ class StrictModeNotifier(
|
|||||||
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||||
|
|
||||||
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
.setSmallIcon(R.drawable.ic_bug)
|
||||||
.setContentTitle(context.getString(R.string.strict_mode))
|
.setContentTitle(context.getString(R.string.strict_mode))
|
||||||
.setContentText(violation.message)
|
.setContentText(violation.message)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
@@ -51,7 +52,15 @@ class StrictModeNotifier(
|
|||||||
.setSummaryText(violation.message)
|
.setSummaryText(violation.message)
|
||||||
.bigText(violation.stackTraceToString()),
|
.bigText(violation.stackTraceToString()),
|
||||||
).setShowWhen(true)
|
).setShowWhen(true)
|
||||||
.setContentIntent(ErrorReporterReceiver.getPendingIntent(context, violation))
|
.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
context,
|
||||||
|
violation.hashCode(),
|
||||||
|
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setGroup(CHANNEL_ID)
|
.setGroup(CHANNEL_ID)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
||||||
@@ -12,8 +13,11 @@ class CurlLoggingInterceptor(
|
|||||||
|
|
||||||
private val escapeRegex = Regex("([\\[\\]\"])")
|
private val escapeRegex = Regex("([\\[\\]\"])")
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
|
||||||
val request = chain.request()
|
logRequest(it.networkResponse?.request ?: it.request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logRequest(request: Request) {
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
|
||||||
val curlCmd = StringBuilder()
|
val curlCmd = StringBuilder()
|
||||||
@@ -46,16 +50,11 @@ class CurlLoggingInterceptor(
|
|||||||
|
|
||||||
log("---cURL (" + request.url + ")")
|
log("---cURL (" + request.url + ")")
|
||||||
log(curlCmd.toString())
|
log(curlCmd.toString())
|
||||||
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.escape() = replace(escapeRegex) { match ->
|
private fun String.escape() = replace(escapeRegex) { match ->
|
||||||
"\\" + match.value
|
"\\" + match.value
|
||||||
}
|
}
|
||||||
// .replace("\"", "\\\"")
|
|
||||||
// .replace("[", "\\[")
|
|
||||||
// .replace("]", "\\]")
|
|
||||||
|
|
||||||
private fun log(msg: String) {
|
private fun log(msg: String) {
|
||||||
Log.d("CURL", msg)
|
Log.d("CURL", msg)
|
||||||
|
|||||||
@@ -0,0 +1,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
app/src/debug/res/drawable-anydpi-v24/ic_bug.xml
Normal file
15
app/src/debug/res/drawable-anydpi-v24/ic_bug.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<group android:scaleX="0.98150784"
|
||||||
|
android:scaleY="0.98150784"
|
||||||
|
android:translateX="0.22190611"
|
||||||
|
android:translateY="-0.2688478">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
BIN
app/src/debug/res/drawable-hdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-hdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
BIN
app/src/debug/res/drawable-mdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-mdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 B |
BIN
app/src/debug/res/drawable-xhdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-xhdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 480 B |
BIN
app/src/debug/res/drawable-xxhdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-xxhdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 792 B |
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,18 @@
|
|||||||
<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:restoreAnyVersion="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 +215,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 +271,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"
|
||||||
@@ -266,19 +299,38 @@
|
|||||||
tools:node="merge" />
|
tools:node="merge" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
|
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:label="@string/local_manga_processing" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
android:name="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupService"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:label="@string/periodic_backups" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
|
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync"
|
||||||
<service android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" />
|
android:label="@string/fixing_manga" />
|
||||||
|
<service
|
||||||
|
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
|
||||||
|
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:permission="android.permission.BIND_REMOTEVIEWS" />
|
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
|
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
|
||||||
|
android:label="@string/recent_manga"
|
||||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
|
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
|
||||||
@@ -315,6 +367,10 @@
|
|||||||
</service>
|
</service>
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/prefetch_content" />
|
||||||
|
<service
|
||||||
|
android:name="org.koitharu.kotatsu.browser.AdListUpdateService"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
@@ -353,6 +409,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"
|
||||||
@@ -394,7 +457,7 @@
|
|||||||
android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
|
android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
|
||||||
|
|
||||||
<activity-alias
|
<activity-alias
|
||||||
android:name="org.koitharu.kotatsu.details.ui.DetailsBYLinkActivity"
|
android:name="org.koitharu.kotatsu.details.ui.DetailsByLinkActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:targetActivity="org.koitharu.kotatsu.details.ui.DetailsActivity">
|
android:targetActivity="org.koitharu.kotatsu.details.ui.DetailsActivity">
|
||||||
|
|
||||||
|
|||||||
@@ -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,23 +4,28 @@ 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 coil.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil.transform.RoundedCornersTransformation
|
import coil3.request.allowRgb565
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import coil3.request.error
|
||||||
|
import coil3.request.fallback
|
||||||
|
import coil3.request.lifecycle
|
||||||
|
import coil3.request.placeholder
|
||||||
|
import coil3.request.transformations
|
||||||
|
import coil3.transform.RoundedCornersTransformation
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.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.newImageRequest
|
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
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
|
||||||
@@ -43,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))
|
||||||
}
|
}
|
||||||
@@ -62,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)
|
||||||
@@ -74,19 +94,11 @@ fun alternativeAD(
|
|||||||
.placeholder(R.drawable.ic_web)
|
.placeholder(R.drawable.ic_web)
|
||||||
.fallback(R.drawable.ic_web)
|
.fallback(R.drawable.ic_web)
|
||||||
.error(R.drawable.ic_web)
|
.error(R.drawable.ic_web)
|
||||||
.source(item.manga.source)
|
.mangaSourceExtra(item.manga.source)
|
||||||
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
|
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
}
|
}
|
||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga)
|
||||||
size(CoverSizeResolver(binding.imageViewCover))
|
|
||||||
defaultPlaceholders(context)
|
|
||||||
transformations(TrimTransformation())
|
|
||||||
allowRgb565(true)
|
|
||||||
tag(item.manga)
|
|
||||||
source(item.manga.source)
|
|
||||||
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 coil.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,26 +10,30 @@ 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 coil.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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.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() {
|
||||||
@@ -44,37 +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 processIntent(startId: Int, intent: Intent) {
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||||
startForeground(startId)
|
startForeground(this)
|
||||||
try {
|
for (mangaId in ids) {
|
||||||
for (mangaId in ids) {
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
val result = runCatchingCancellable {
|
val result = runCatchingCancellable {
|
||||||
autoFixUseCase.invoke(mangaId)
|
autoFixUseCase.invoke(mangaId)
|
||||||
}
|
}
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
val notification = buildNotification(result)
|
val notification = buildNotification(startId, result)
|
||||||
notificationManager.notify(TAG, startId, notification)
|
notificationManager.notify(TAG, startId, notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(startId: Int, 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(startId: Int) {
|
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)
|
||||||
@@ -84,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)
|
||||||
@@ -95,22 +97,21 @@ 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),
|
||||||
getCancelIntent(startId),
|
jobContext.getCancelIntent(),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
ServiceCompat.startForeground(
|
jobContext.setForeground(
|
||||||
this,
|
|
||||||
FOREGROUND_NOTIFICATION_ID,
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
notification,
|
notification,
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -119,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)
|
||||||
.tag(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,314 @@
|
|||||||
|
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.ScrobblingBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.SourceBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
|
||||||
|
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.explore.data.MangaSourcesRepository
|
||||||
|
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||||
|
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
||||||
|
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 mangaSourcesRepository: MangaSourcesRepository,
|
||||||
|
private val savedFiltersRepository: SavedFiltersRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SCROBBLING -> output.writeJsonArray(
|
||||||
|
section = BackupSection.SCROBBLING,
|
||||||
|
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.STATS -> output.writeJsonArray(
|
||||||
|
section = BackupSection.STATS,
|
||||||
|
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SAVED_FILTERS -> {
|
||||||
|
val sources = mangaSourcesRepository.getEnabledSources()
|
||||||
|
val filters = sources.flatMap { source ->
|
||||||
|
savedFiltersRepository.getAll(source)
|
||||||
|
}
|
||||||
|
output.writeJsonArray(
|
||||||
|
section = BackupSection.SAVED_FILTERS,
|
||||||
|
data = filters.asFlow(),
|
||||||
|
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 += 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
|
||||||
|
getScrobblingDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
|
||||||
|
getStatsDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SAVED_FILTERS -> input.readJsonArray<PersistableFilter>(serializer())
|
||||||
|
.restoreWithoutTransaction {
|
||||||
|
savedFiltersRepository.save(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <T> Sequence<T>.restoreWithoutTransaction(crossinline block: suspend (T) -> Unit): CompositeResult {
|
||||||
|
return fold(CompositeResult.EMPTY) { result, item ->
|
||||||
|
result + runCatchingCancellable {
|
||||||
|
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,40 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ScrobblingBackup(
|
||||||
|
@SerialName("scrobbler") val scrobbler: Int,
|
||||||
|
@SerialName("id") val id: Int,
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("target_id") val targetId: Long,
|
||||||
|
@SerialName("status") val status: String?,
|
||||||
|
@SerialName("chapter") val chapter: Int,
|
||||||
|
@SerialName("comment") val comment: String?,
|
||||||
|
@SerialName("rating") val rating: Float,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: ScrobblingEntity) : this(
|
||||||
|
scrobbler = entity.scrobbler,
|
||||||
|
id = entity.id,
|
||||||
|
mangaId = entity.mangaId,
|
||||||
|
targetId = entity.targetId,
|
||||||
|
status = entity.status,
|
||||||
|
chapter = entity.chapter,
|
||||||
|
comment = entity.comment,
|
||||||
|
rating = entity.rating,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = ScrobblingEntity(
|
||||||
|
scrobbler = scrobbler,
|
||||||
|
id = id,
|
||||||
|
mangaId = mangaId,
|
||||||
|
targetId = targetId,
|
||||||
|
status = status,
|
||||||
|
chapter = chapter,
|
||||||
|
comment = comment,
|
||||||
|
rating = rating,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,28 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.stats.data.StatsEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class StatisticBackup(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("started_at") val startedAt: Long,
|
||||||
|
@SerialName("duration") val duration: Long,
|
||||||
|
@SerialName("pages") val pages: Int,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: StatsEntity) : this(
|
||||||
|
mangaId = entity.mangaId,
|
||||||
|
startedAt = entity.startedAt,
|
||||||
|
duration = entity.duration,
|
||||||
|
pages = entity.pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = StatsEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
startedAt = startedAt,
|
||||||
|
duration = duration,
|
||||||
|
pages = pages,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,119 @@
|
|||||||
|
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.explore.data.MangaSourcesRepository
|
||||||
|
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
||||||
|
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(
|
||||||
|
database = MangaDatabase(context = applicationContext),
|
||||||
|
settings = AppSettings(applicationContext),
|
||||||
|
tapGridSettings = TapGridSettings(applicationContext),
|
||||||
|
mangaSourcesRepository = MangaSourcesRepository(
|
||||||
|
context = applicationContext,
|
||||||
|
db = MangaDatabase(context = applicationContext),
|
||||||
|
settings = AppSettings(applicationContext),
|
||||||
|
),
|
||||||
|
savedFiltersRepository = SavedFiltersRepository(
|
||||||
|
context = 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),
|
||||||
|
mangaSourcesRepository = MangaSourcesRepository(
|
||||||
|
context = applicationContext,
|
||||||
|
db = MangaDatabase(context = applicationContext),
|
||||||
|
settings = AppSettings(applicationContext),
|
||||||
|
),
|
||||||
|
savedFiltersRepository = SavedFiltersRepository(
|
||||||
|
context = 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 ->
|
||||||
|
val sections = EnumSet.allOf(BackupSection::class.java)
|
||||||
|
// managed externally
|
||||||
|
sections.remove(BackupSection.SETTINGS)
|
||||||
|
sections.remove(BackupSection.SETTINGS_READER_GRID)
|
||||||
|
runBlocking {
|
||||||
|
repository.restoreBackup(input, sections, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
data class BackupFile(
|
||||||
|
val uri: Uri,
|
||||||
|
val dateTime: Date,
|
||||||
|
) : Comparable<BackupFile> {
|
||||||
|
|
||||||
|
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,30 @@
|
|||||||
|
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"),
|
||||||
|
SCROBBLING("scrobbling"),
|
||||||
|
STATS("statistics"),
|
||||||
|
SAVED_FILTERS("saved_filters"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(entry: ZipEntry): BackupSection? {
|
||||||
|
val name = entry.name.lowercase(Locale.ROOT)
|
||||||
|
return entries.find { 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import okio.source
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ExternalBackupStorage @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun list(): List<BackupFile> = runInterruptible(Dispatchers.IO) {
|
||||||
|
getRootOrThrow().listFiles().mapNotNull {
|
||||||
|
if (it.isFile && it.canRead()) {
|
||||||
|
BackupFile(
|
||||||
|
uri = it.uri,
|
||||||
|
dateTime = it.name?.let { fileName ->
|
||||||
|
BackupUtils.parseBackupDateTime(fileName)
|
||||||
|
} ?: return@mapNotNull null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.sortedDescending()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listOrNull() = runCatchingCancellable {
|
||||||
|
list()
|
||||||
|
}.onFailure { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
|
||||||
|
val out = checkNotNull(
|
||||||
|
getRootOrThrow().createFile(
|
||||||
|
"application/zip",
|
||||||
|
file.nameWithoutExtension,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
"Cannot create target backup file"
|
||||||
|
}
|
||||||
|
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
|
||||||
|
file.source().buffer().use { src ->
|
||||||
|
src.readAll(sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
|
||||||
|
val df = DocumentFile.fromSingleUri(context, victim.uri)
|
||||||
|
df != null && df.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLastBackupDate() = listOrNull()?.maxOfOrNull { it.dateTime }
|
||||||
|
|
||||||
|
suspend fun trim(maxCount: Int): Boolean {
|
||||||
|
if (maxCount == Int.MAX_VALUE) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val list = listOrNull()
|
||||||
|
if (list == null || list.size <= maxCount) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var result = false
|
||||||
|
for (i in maxCount until list.size) {
|
||||||
|
if (delete(list[i])) {
|
||||||
|
result = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
private fun getRootOrThrow(): DocumentFile {
|
||||||
|
val uri = checkNotNull(settings.periodicalBackupDirectory) {
|
||||||
|
"Backup directory is not specified"
|
||||||
|
}
|
||||||
|
val root = DocumentFile.fromTreeUri(context, uri)
|
||||||
|
return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.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,47 @@
|
|||||||
|
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
|
||||||
|
BackupSection.SCROBBLING -> R.string.tracking
|
||||||
|
BackupSection.STATS -> R.string.statistics
|
||||||
|
BackupSection.SAVED_FILTERS -> R.string.saved_filters
|
||||||
|
}
|
||||||
|
|
||||||
|
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 coil.ImageLoader
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.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,23 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
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.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.core.util.ext.source
|
|
||||||
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) },
|
||||||
@@ -25,15 +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)
|
|
||||||
tag(item)
|
|
||||||
decodeRegion(item.scroll)
|
|
||||||
source(item.manga.source)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
binding.progressView.setProgress(item.percent, false)
|
binding.progressView.setProgress(item.percent, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil.ImageLoader
|
|
||||||
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.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.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
|
||||||
|
|
||||||
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)
|
|
||||||
tag(item)
|
|
||||||
decodeRegion(item.scroll)
|
|
||||||
source(item.manga.source)
|
|
||||||
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 coil.ImageLoader
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
@@ -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 coil.EventListener
|
|
||||||
import coil.request.ErrorResult
|
|
||||||
import coil.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_DEFAULT)
|
|
||||||
.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_DEFAULT)
|
|
||||||
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
|
||||||
.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.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
|
|
||||||
notify(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
|
|
||||||
key = PARAM_IGNORE_CAPTCHA,
|
|
||||||
value = true,
|
|
||||||
memoryCacheKey = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,22 @@ package org.koitharu.kotatsu.core
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import 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 coil.ComponentRegistry
|
import coil3.ImageLoader
|
||||||
import coil.ImageLoader
|
import coil3.disk.DiskCache
|
||||||
import coil.decode.SvgDecoder
|
import coil3.disk.directory
|
||||||
import coil.disk.DiskCache
|
import coil3.gif.AnimatedImageDecoder
|
||||||
import coil.util.DebugLogger
|
import coil3.gif.GifDecoder
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import coil3.request.allowRgb565
|
||||||
|
import coil3.svg.SvgDecoder
|
||||||
|
import coil3.util.DebugLogger
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -25,33 +31,38 @@ 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.CbzFetcher
|
||||||
|
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
import org.koitharu.kotatsu.core.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.CbzFetcher
|
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
|
||||||
@@ -69,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(
|
||||||
@@ -80,19 +97,19 @@ interface AppModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideMangaDatabase(
|
fun provideMangaDatabase(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): MangaDatabase {
|
): MangaDatabase = MangaDatabase(context)
|
||||||
return MangaDatabase(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@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>,
|
||||||
|
captchaHandler: CaptchaHandler,
|
||||||
): ImageLoader {
|
): ImageLoader {
|
||||||
val diskCacheFactory = {
|
val diskCacheFactory = {
|
||||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||||
@@ -104,36 +121,39 @@ interface AppModule {
|
|||||||
okHttpClientProvider.get().newBuilder().cache(null).build()
|
okHttpClientProvider.get().newBuilder().cache(null).build()
|
||||||
}
|
}
|
||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.okHttpClient { okHttpClientLazy.value }
|
.interceptorCoroutineContext(Dispatchers.Default)
|
||||||
.interceptorDispatcher(Dispatchers.Default)
|
|
||||||
.fetcherDispatcher(Dispatchers.Default)
|
|
||||||
.decoderDispatcher(Dispatchers.IO)
|
|
||||||
.transformationDispatcher(Dispatchers.Default)
|
|
||||||
.diskCache(diskCacheFactory)
|
.diskCache(diskCacheFactory)
|
||||||
.respectCacheHeaders(false)
|
|
||||||
.networkObserverEnabled(false)
|
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
.allowRgb565(context.isLowRamDevice())
|
.allowRgb565(context.isLowRamDevice())
|
||||||
.eventListener(CaptchaNotifier(context))
|
.eventListener(captchaHandler)
|
||||||
.components(
|
.components {
|
||||||
ComponentRegistry.Builder()
|
add(
|
||||||
.add(SvgDecoder.Factory())
|
OkHttpNetworkFetcherFactory(
|
||||||
.add(CbzFetcher.Factory())
|
callFactory = okHttpClientLazy::value,
|
||||||
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
connectivityChecker = { networkStateProvider.get() },
|
||||||
.add(MangaPageKeyer())
|
),
|
||||||
.add(pageFetcherFactory)
|
)
|
||||||
.add(imageProxyInterceptor)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
.add(coverRestoreInterceptor)
|
add(AnimatedImageDecoder.Factory())
|
||||||
.build(),
|
} else {
|
||||||
).build()
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
|
add(CbzFetcher.Factory())
|
||||||
|
add(AvifImageDecoder.Factory())
|
||||||
|
add(faviconFetcherFactory)
|
||||||
|
add(MangaPageKeyer())
|
||||||
|
add(pageFetcherFactory)
|
||||||
|
add(imageProxyInterceptor)
|
||||||
|
add(coverRestoreInterceptor)
|
||||||
|
add(MangaSourceHeaderInterceptor())
|
||||||
|
}.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideSearchSuggestions(
|
fun provideSearchSuggestions(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): SearchRecentSuggestions {
|
): SearchRecentSuggestions = MangaSuggestionsProvider.createSuggestions(context)
|
||||||
return MangaSuggestionsProvider.createSuggestions(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@ElementsIntoSet
|
@ElementsIntoSet
|
||||||
@@ -178,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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import androidx.appcompat.app.AppCompatDelegate
|
|||||||
import androidx.hilt.work.HiltWorkerFactory
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
import androidx.room.InvalidationTracker
|
import androidx.room.InvalidationTracker
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.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 +25,13 @@ 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.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
|
||||||
@@ -61,9 +61,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var workScheduleManager: WorkScheduleManager
|
lateinit var workScheduleManager: WorkScheduleManager
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||||
|
|
||||||
@@ -78,29 +75,32 @@ 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()) {
|
||||||
|
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()
|
||||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||||
}
|
}
|
||||||
workScheduleManager.init()
|
workScheduleManager.init()
|
||||||
WorkServiceStopHelper(workManagerProvider).setup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.JSONIterator
|
|
||||||
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.JSONIterator()) {
|
|
||||||
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.JSONIterator()) {
|
|
||||||
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.JSONIterator()) {
|
|
||||||
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.JSONIterator()) {
|
|
||||||
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.JSONIterator()) {
|
|
||||||
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.JSONIterator()) {
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
settings.upsertAll(JsonDeserializer(item).toMap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okhttp3.internal.closeQuietly
|
|
||||||
import okio.Closeable
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|
||||||
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 cleanupAsync() {
|
|
||||||
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
|
||||||
runCatching {
|
|
||||||
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: Exception) {
|
|
||||||
res?.closeQuietly()
|
|
||||||
throw if (exception is ZipException) {
|
|
||||||
BadBackupFormatException(exception)
|
|
||||||
} else {
|
|
||||||
exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +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.zip.ZipOutput
|
|
||||||
import java.io.File
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const val DIR_BACKUPS = "backups"
|
|
||||||
|
|
||||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
|
||||||
val dir = context.run {
|
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
}
|
|
||||||
dir.mkdirs()
|
|
||||||
val filename = buildString {
|
|
||||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
|
||||||
append('_')
|
|
||||||
append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")))
|
|
||||||
append(".bk.zip")
|
|
||||||
}
|
|
||||||
BackupZipOutput(File(dir, filename))
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user