Compare commits
492 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3345d11e7 | ||
|
|
f1ab65ec32 | ||
|
|
6282d25d3d | ||
|
|
47c3f9ff3b | ||
|
|
5cd2f1b9e6 | ||
|
|
5d9b18ec11 | ||
|
|
5aec1f644d | ||
|
|
aee092f0b3 | ||
|
|
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 | ||
|
|
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 | ||
|
|
e4fda86bf1 | ||
|
|
6e20cee972 | ||
|
|
8901d02dba | ||
|
|
a87b37ce1c | ||
|
|
4f22e29ad6 | ||
|
|
6effb928fd | ||
|
|
1b1d0014da | ||
|
|
a9632f542b | ||
|
|
a2c256d47f | ||
|
|
f87a75e61e | ||
|
|
09354ae31f | ||
|
|
fb25b8fb3a | ||
|
|
c8b935ccc3 | ||
|
|
7f0376d792 | ||
|
|
0c56e730fe | ||
|
|
a7138d23ac | ||
|
|
a0de73a7ed | ||
|
|
90f0846fb4 | ||
|
|
9425d29596 | ||
|
|
ad0452486f | ||
|
|
436168b940 | ||
|
|
681c80dc3e | ||
|
|
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 | ||
|
|
b81063910b | ||
|
|
702ee70f70 | ||
|
|
c5bd979645 | ||
|
|
3255fba3c4 | ||
|
|
144e66bedb | ||
|
|
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 |
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,3 +26,4 @@
|
|||||||
.cxx
|
.cxx
|
||||||
/.idea/deviceManager.xml
|
/.idea/deviceManager.xml
|
||||||
/.kotlin/
|
/.kotlin/
|
||||||
|
/.idea/AndroidProjectSystem.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>
|
||||||
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -6,14 +6,13 @@
|
|||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="jbr-21" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
<option name="resolveExternalAnnotations" value="false" />
|
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
105
README.md
105
README.md
@@ -1,56 +1,107 @@
|
|||||||
# 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 (very unstable, use at your own risk).
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
### Main Features
|
### Main Features
|
||||||
|
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
<div align="left">
|
||||||
|
|
||||||
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1100+ manga sources)
|
||||||
* Search manga by name, genres, and more filters
|
* Search manga by name, genres, and more filters
|
||||||
* Reading history and bookmarks
|
|
||||||
* Favorites organized by user-defined categories
|
* Favorites organized by user-defined categories
|
||||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
* Reading history, bookmarks, and incognito mode support
|
||||||
* Tablet-optimized Material You UI
|
* Download manga and read it offline. Third-party CBZ archives are also supported
|
||||||
* Standard and Webtoon-optimized customizable reader
|
* Clean and convenient Material You UI, optimized for phones, tablets, and desktop
|
||||||
* Notifications about new chapters with updates feed
|
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
|
||||||
|
* Notifications about new chapters with updates feed, manga recommendations (with filters)
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
* Password/fingerprint-protected access to the app
|
* Password / fingerprint-protected access to the app
|
||||||
|
* Automatically sync app data with other devices on the same account
|
||||||
|
* Support for older devices running Android 5+
|
||||||
|
|
||||||
### 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**
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
||||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
<div align="left">
|
||||||
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
|
||||||
install instructions.
|
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
### DMCA disclaimer
|
### DMCA disclaimer
|
||||||
|
|
||||||
The developers of this application do not have any affiliation with the content available in the app.
|
<div align="left">
|
||||||
It collects content from sources that are freely available through any web browser
|
|
||||||
|
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
172
app/build.gradle
172
app/build.gradle
@@ -1,3 +1,5 @@
|
|||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'kotlin-android'
|
id 'kotlin-android'
|
||||||
@@ -5,6 +7,7 @@ plugins {
|
|||||||
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'
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -16,13 +19,12 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 680
|
versionCode = 1004
|
||||||
versionName = '7.6.7'
|
versionName = '8.0'
|
||||||
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
|
generateLocaleConfig true
|
||||||
@@ -37,11 +39,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/'
|
||||||
@@ -59,12 +73,16 @@ android {
|
|||||||
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
||||||
|
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
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,6 +91,15 @@ android {
|
|||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
applicationVariants.configureEach { variant ->
|
||||||
|
if (variant.name == 'nightly') {
|
||||||
|
variant.outputs.each { output ->
|
||||||
|
def now = LocalDateTime.now()
|
||||||
|
output.versionCodeOverride = now.format("yyMMdd").toInteger()
|
||||||
|
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
compileDebugKotlin {
|
compileDebugKotlin {
|
||||||
@@ -82,87 +109,92 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:f80b586081') {
|
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.3'
|
implementation libs.androidx.activity
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.8.5'
|
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.swiperefreshlayout
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
implementation libs.androidx.recyclerview
|
||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0'
|
implementation libs.androidx.viewpager2
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
implementation libs.androidx.preference
|
||||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
implementation libs.androidx.biometric
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation libs.material
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
|
implementation libs.androidx.lifecycle.common.java8
|
||||||
implementation 'androidx.webkit:webkit:1.11.0'
|
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 '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'
|
kapt libs.hilt.compiler
|
||||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
implementation libs.androidx.hilt.work
|
||||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
kapt 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 'org.aomedia.avif.android:avif:1.1.1.14d8e3c4'
|
implementation libs.coil.gif
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d1d10a6975'
|
implementation libs.coil.svg
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation libs.avif.decoder
|
||||||
implementation 'io.noties.markwon:core:4.6.2'
|
implementation libs.ssiv
|
||||||
|
implementation libs.disk.lru.cache
|
||||||
|
implementation libs.markwon
|
||||||
|
|
||||||
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'
|
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'
|
kaptAndroidTest libs.hilt.android.compiler
|
||||||
}
|
}
|
||||||
|
|||||||
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
@@ -15,6 +15,7 @@
|
|||||||
-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.core.exceptions.* { *; }
|
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||||
@@ -26,3 +27,4 @@
|
|||||||
-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,9 +1,12 @@
|
|||||||
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.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
@@ -13,9 +16,23 @@ 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() {
|
||||||
@@ -55,7 +72,7 @@ class KotatsuApp : BaseApp() {
|
|||||||
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 +87,13 @@ class KotatsuApp : BaseApp() {
|
|||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
const val PREFS_DEBUG = "_debug"
|
||||||
|
const val KEY_LEAK_CANARY = "leak_canary"
|
||||||
|
|
||||||
|
fun getDebugPreferences(context: Context): SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFS_DEBUG, MODE_PRIVATE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class StrictModeNotifier(
|
|||||||
.setContentIntent(
|
.setContentIntent(
|
||||||
PendingIntentCompat.getActivity(
|
PendingIntentCompat.getActivity(
|
||||||
context,
|
context,
|
||||||
0,
|
violation.hashCode(),
|
||||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import android.view.MenuInflater
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
import leakcanary.LeakCanary
|
import leakcanary.LeakCanary
|
||||||
|
import org.koitharu.kotatsu.KotatsuApp
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.workinspector.WorkInspector
|
import org.koitharu.workinspector.WorkInspector
|
||||||
|
|
||||||
@@ -13,10 +14,18 @@ class SettingsMenuProvider(
|
|||||||
private val context: Context,
|
private val context: Context,
|
||||||
) : MenuProvider {
|
) : MenuProvider {
|
||||||
|
|
||||||
|
private val application: KotatsuApp
|
||||||
|
get() = context.applicationContext as KotatsuApp
|
||||||
|
|
||||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPrepareMenu(menu: Menu) {
|
||||||
|
super.onPrepareMenu(menu)
|
||||||
|
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
|
||||||
|
}
|
||||||
|
|
||||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||||
R.id.action_leaks -> {
|
R.id.action_leaks -> {
|
||||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||||
@@ -28,6 +37,13 @@ class SettingsMenuProvider(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.action_leakcanary -> {
|
||||||
|
val checked = !menuItem.isChecked
|
||||||
|
menuItem.isChecked = checked
|
||||||
|
application.isLeakCanaryEnabled = checked
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<menu
|
<menu
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@id/action_leaks"
|
android:id="@+id/action_leakcanary"
|
||||||
|
android:checkable="true"
|
||||||
|
android:title="LeakCanary"
|
||||||
|
app:showAsAction="never"
|
||||||
|
tools:ignore="HardcodedText" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_leaks"
|
||||||
android:title="@string/leak_canary_display_activity_label"
|
android:title="@string/leak_canary_display_activity_label"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@id/action_works"
|
android:id="@+id/action_works"
|
||||||
android:title="@string/wi_lib_name"
|
android:title="@string/wi_lib_name"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,10 @@
|
|||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
|
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
|
||||||
android:dataExtractionRules="@xml/backup_rules"
|
android:dataExtractionRules="@xml/backup_rules"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
|
||||||
android:fullBackupContent="@xml/backup_content"
|
android:fullBackupContent="@xml/backup_content"
|
||||||
android:fullBackupOnly="true"
|
android:fullBackupOnly="true"
|
||||||
|
android:hasFragileUserData="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
@@ -209,6 +210,7 @@
|
|||||||
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
|
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
|
||||||
<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"
|
||||||
@@ -266,19 +268,34 @@
|
|||||||
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.settings.backup.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.settings.backup.RestoreService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:label="@string/restore_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,7 +332,8 @@
|
|||||||
</service>
|
</service>
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
||||||
android:exported="false" />
|
android:exported="false"
|
||||||
|
android:label="@string/prefetch_content" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
|
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
|
||||||
@@ -394,7 +412,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) {
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ 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
|
||||||
@@ -19,8 +27,10 @@ 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.defaultPlaceholders
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaExtra
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.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 +53,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 +84,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,7 +99,7 @@ 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)
|
||||||
@@ -84,8 +109,7 @@ fun alternativeAD(
|
|||||||
defaultPlaceholders(context)
|
defaultPlaceholders(context)
|
||||||
transformations(TrimTransformation())
|
transformations(TrimTransformation())
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
tag(item.manga)
|
mangaExtra(item.manga)
|
||||||
source(item.manga.source)
|
|
||||||
enqueueWith(coil)
|
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
|
||||||
@@ -55,6 +54,7 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, 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,22 +10,24 @@ 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.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.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
|
||||||
@@ -47,11 +49,11 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -60,12 +62,10 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
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 (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||||
notificationManager.notify(TAG, startId, notification)
|
notificationManager.notify(TAG, startId, notification)
|
||||||
@@ -73,7 +73,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
private fun startForeground(startId: Int) {
|
private fun startForeground(jobContext: IntentJobContext) {
|
||||||
val title = applicationContext.getString(R.string.fixing_manga)
|
val title = applicationContext.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)
|
||||||
@@ -97,12 +97,11 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
.addAction(
|
.addAction(
|
||||||
materialR.drawable.material_ic_clear_black_24dp,
|
materialR.drawable.material_ic_clear_black_24dp,
|
||||||
applicationContext.getString(android.R.string.cancel),
|
applicationContext.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,
|
||||||
@@ -121,12 +120,12 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
coil.execute(
|
coil.execute(
|
||||||
ImageRequest.Builder(applicationContext)
|
ImageRequest.Builder(applicationContext)
|
||||||
.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(applicationContext, replacement)
|
||||||
notification.setContentIntent(
|
notification.setContentIntent(
|
||||||
PendingIntentCompat.getActivity(
|
PendingIntentCompat.getActivity(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 @@ 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 coil3.ImageLoader
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.nav.ReaderIntent
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
|
||||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
@@ -39,7 +40,6 @@ 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 javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -107,6 +107,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 +127,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(true)
|
||||||
.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
|
||||||
@@ -177,16 +189,6 @@ class AllBookmarksFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
val rv = requireViewBinding().recyclerView
|
|
||||||
rv.updatePadding(
|
|
||||||
bottom = insets.bottom + rv.paddingTop,
|
|
||||||
)
|
|
||||||
rv.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
|
||||||
bottomMargin = insets.bottom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
|
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -208,16 +210,4 @@ class AllBookmarksFragment :
|
|||||||
invalidateSpanIndexCache()
|
invalidateSpanIndexCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
"",
|
|
||||||
ReplaceWith(
|
|
||||||
"BookmarksFragment()",
|
|
||||||
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
fun newInstance() = AllBookmarksFragment()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil3.ImageLoader
|
||||||
|
import coil3.request.allowRgb565
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
@@ -29,9 +30,8 @@ fun bookmarkLargeAD(
|
|||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
size(CoverSizeResolver(binding.imageViewThumb))
|
||||||
defaultPlaceholders(context)
|
defaultPlaceholders(context)
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
tag(item)
|
bookmarkExtra(item)
|
||||||
decodeRegion(item.scroll)
|
decodeRegion(item.scroll)
|
||||||
source(item.manga.source)
|
|
||||||
enqueueWith(coil)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil3.ImageLoader
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
@@ -10,6 +10,7 @@ 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
|
||||||
@@ -25,6 +26,7 @@ class BookmarksAdapter(
|
|||||||
init {
|
init {
|
||||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, 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(coil, lifecycleOwner, null))
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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.network.proxy.ProxyProvider
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.consumeAll
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var proxyProvider: ProxyProvider
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,57 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Context
|
|
||||||
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.lifecycle.lifecycleScope
|
||||||
import androidx.core.graphics.Insets
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import androidx.core.view.isVisible
|
|
||||||
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.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
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.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
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 javax.inject.Inject
|
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
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
setDisplayHomeAsUp(true, true)
|
||||||
return
|
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
|
||||||
}
|
|
||||||
supportActionBar?.run {
|
|
||||||
setDisplayHomeAsUpEnabled(true)
|
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
|
||||||
}
|
|
||||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
|
||||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||||
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||||
viewBinding.webView.configureForParser(userAgent)
|
viewBinding.webView.configureForParser(userAgent)
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
lifecycleScope.launch {
|
||||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
try {
|
||||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
proxyProvider.applyWebViewConfig()
|
||||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
} catch (e: Exception) {
|
||||||
if (savedInstanceState != null) {
|
e.printStackTraceDebug()
|
||||||
return
|
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
val url = intent?.dataString
|
if (savedInstanceState == null) {
|
||||||
if (url.isNullOrEmpty()) {
|
val url = intent?.dataString
|
||||||
finishAfterTransition()
|
if (url.isNullOrEmpty()) {
|
||||||
} else {
|
finishAfterTransition()
|
||||||
onTitleChanged(
|
} else {
|
||||||
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
|
onTitleChanged(
|
||||||
url,
|
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
|
||||||
)
|
url,
|
||||||
viewBinding.webView.loadUrl(url)
|
)
|
||||||
|
viewBinding.webView.loadUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,73 +69,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
|
||||||
viewBinding.appbar.updatePadding(
|
|
||||||
top = insets.top,
|
|
||||||
)
|
|
||||||
viewBinding.root.updatePadding(
|
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
bottom = insets.bottom,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val EXTRA_TITLE = "title"
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package org.koitharu.kotatsu.browser
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import androidx.webkit.WebViewClientCompat
|
||||||
|
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||||
|
|
||||||
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
|
open class BrowserClient(
|
||||||
|
private val proxyProvider: ProxyProvider,
|
||||||
|
private val callback: BrowserCallback
|
||||||
|
) : WebViewClientCompat() {
|
||||||
|
|
||||||
override fun onPageFinished(webView: WebView, url: String) {
|
override fun onPageFinished(webView: WebView, url: String) {
|
||||||
super.onPageFinished(webView, url)
|
super.onPageFinished(webView, url)
|
||||||
@@ -16,7 +20,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,30 @@ 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.net.toUri
|
import androidx.core.net.toUri
|
||||||
import coil.EventListener
|
import coil3.EventListener
|
||||||
import coil.request.ErrorResult
|
import coil3.Extras
|
||||||
import coil.request.ImageRequest
|
import coil3.request.ErrorResult
|
||||||
|
import coil3.request.ImageRequest
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
class CaptchaNotifier(
|
class CaptchaNotifier(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) : EventListener {
|
) : EventListener() {
|
||||||
|
|
||||||
fun notify(exception: CloudFlareProtectedException) {
|
fun notify(exception: CloudFlareProtectedException) {
|
||||||
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (exception.source != null && SourceSettings(context, exception.source).isCaptchaNotificationsDisabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||||
.setName(context.getString(R.string.captcha_required))
|
.setName(context.getString(R.string.captcha_required))
|
||||||
@@ -37,13 +43,13 @@ class CaptchaNotifier(
|
|||||||
.build()
|
.build()
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
|
|
||||||
val intent = CloudFlareActivity.newIntent(context, exception)
|
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
|
||||||
.setData(exception.url.toUri())
|
.setData(exception.url.toUri())
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setContentTitle(channel.name)
|
.setContentTitle(channel.name)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.setDefaults(0)
|
.setDefaults(0)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
.setSmallIcon(R.drawable.ic_bot)
|
||||||
.setGroup(GROUP_CAPTCHA)
|
.setGroup(GROUP_CAPTCHA)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setVisibility(
|
.setVisibility(
|
||||||
@@ -84,20 +90,19 @@ class CaptchaNotifier(
|
|||||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||||
super.onError(request, result)
|
super.onError(request, result)
|
||||||
val e = result.throwable
|
val e = result.throwable
|
||||||
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
|
if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) {
|
||||||
notify(e)
|
notify(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
|
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
||||||
key = PARAM_IGNORE_CAPTCHA,
|
extras[ignoreCaptchaKey] = true
|
||||||
value = true,
|
}
|
||||||
memoryCacheKey = null,
|
|
||||||
)
|
val ignoreCaptchaKey = Extras.Key(false)
|
||||||
|
|
||||||
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
|
|
||||||
private const val CHANNEL_ID = "captcha"
|
private const val CHANNEL_ID = "captcha"
|
||||||
private const val TAG = CHANNEL_ID
|
private const val TAG = CHANNEL_ID
|
||||||
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
||||||
|
|||||||
@@ -1,43 +1,34 @@
|
|||||||
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.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.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
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
|
||||||
|
|
||||||
@@ -45,43 +36,29 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
lateinit var cookieJar: MutableCookieJar
|
lateinit var cookieJar: MutableCookieJar
|
||||||
|
|
||||||
private lateinit var cfClient: CloudFlareClient
|
private lateinit var cfClient: CloudFlareClient
|
||||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
setDisplayHomeAsUp(true, true)
|
||||||
return
|
|
||||||
}
|
|
||||||
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(proxyProvider, cookieJar, this, url)
|
||||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
|
||||||
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 +66,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 +81,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,21 +98,13 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
|||||||
|
|
||||||
override fun onCheckPassed() {
|
override fun onCheckPassed() {
|
||||||
pendingResult = RESULT_OK
|
pendingResult = RESULT_OK
|
||||||
val source = intent?.getStringExtra(ARG_SOURCE)
|
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
CaptchaNotifier(this).dismiss(MangaSource(source))
|
CaptchaNotifier(this).dismiss(MangaSource(source))
|
||||||
}
|
}
|
||||||
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 =
|
||||||
@@ -182,38 +132,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,15 +4,17 @@ 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.proxy.ProxyProvider
|
||||||
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
|
||||||
|
|
||||||
class CloudFlareClient(
|
class CloudFlareClient(
|
||||||
|
proxyProvider: ProxyProvider,
|
||||||
private val cookieJar: MutableCookieJar,
|
private val cookieJar: MutableCookieJar,
|
||||||
private val callback: CloudFlareCallback,
|
private val callback: CloudFlareCallback,
|
||||||
private val targetUrl: String,
|
private val targetUrl: String,
|
||||||
) : BrowserClient(callback) {
|
) : BrowserClient(proxyProvider, callback) {
|
||||||
|
|
||||||
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
|
||||||
@@ -28,6 +34,8 @@ import org.koitharu.kotatsu.BuildConfig
|
|||||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.image.AvifImageDecoder
|
import org.koitharu.kotatsu.core.image.AvifImageDecoder
|
||||||
|
import org.koitharu.kotatsu.core.image.CbzFetcher
|
||||||
|
import org.koitharu.kotatsu.core.image.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
|
||||||
@@ -44,7 +52,6 @@ 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.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||||
@@ -70,6 +77,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(
|
||||||
@@ -86,12 +99,13 @@ interface AppModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideCoil(
|
fun provideCoil(
|
||||||
@ApplicationContext context: Context,
|
@LocalizedAppContext context: Context,
|
||||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
imageProxyInterceptor: ImageProxyInterceptor,
|
imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||||
|
networkStateProvider: Provider<NetworkState>,
|
||||||
): ImageLoader {
|
): ImageLoader {
|
||||||
val diskCacheFactory = {
|
val diskCacheFactory = {
|
||||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||||
@@ -103,37 +117,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(CaptchaNotifier(context))
|
||||||
.components(
|
.components {
|
||||||
ComponentRegistry.Builder()
|
add(
|
||||||
.add(SvgDecoder.Factory())
|
OkHttpNetworkFetcherFactory(
|
||||||
.add(CbzFetcher.Factory())
|
callFactory = okHttpClientLazy::value,
|
||||||
.add(AvifImageDecoder.Factory())
|
connectivityChecker = { networkStateProvider.get() },
|
||||||
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
),
|
||||||
.add(MangaPageKeyer())
|
)
|
||||||
.add(pageFetcherFactory)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
.add(imageProxyInterceptor)
|
add(AnimatedImageDecoder.Factory())
|
||||||
.add(coverRestoreInterceptor)
|
} else {
|
||||||
.build(),
|
add(GifDecoder.Factory())
|
||||||
).build()
|
}
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
|
add(CbzFetcher.Factory())
|
||||||
|
add(AvifImageDecoder.Factory())
|
||||||
|
add(FaviconFetcher.Factory(mangaRepositoryFactory))
|
||||||
|
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
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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 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,14 @@ import org.koitharu.kotatsu.BuildConfig
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.os.AppValidator
|
import org.koitharu.kotatsu.core.os.AppValidator
|
||||||
|
import org.koitharu.kotatsu.core.os.RomCompat
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
|
||||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -78,18 +79,18 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
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()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.net.Uri
|
|||||||
import android.os.BadParcelableException
|
import android.os.BadParcelableException
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.core.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,20 +16,19 @@ 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
|
||||||
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"
|
||||||
|
|
||||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
||||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||||
intent.setAction(ACTION_REPORT)
|
intent.setAction(ACTION_REPORT)
|
||||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
||||||
intent.putExtra(EXTRA_ERROR, e)
|
intent.putExtra(AppRouter.KEY_ERROR, e)
|
||||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||||
} catch (e: BadParcelableException) {
|
} catch (e: BadParcelableException) {
|
||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -16,6 +16,7 @@ class BackupEntry(
|
|||||||
CATEGORIES("categories"),
|
CATEGORIES("categories"),
|
||||||
FAVOURITES("favourites"),
|
FAVOURITES("favourites"),
|
||||||
SETTINGS("settings"),
|
SETTINGS("settings"),
|
||||||
|
SETTINGS_READER_GRID("reader_grid"),
|
||||||
BOOKMARKS("bookmarks"),
|
BOOKMARKS("bookmarks"),
|
||||||
SOURCES("sources"),
|
SOURCES("sources"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
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,15 +1,18 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ private const val PAGE_SIZE = 10
|
|||||||
class BackupRepository @Inject constructor(
|
class BackupRepository @Inject constructor(
|
||||||
private val db: MangaDatabase,
|
private val db: MangaDatabase,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
|
private val tapGridSettings: TapGridSettings,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun dumpHistory(): BackupEntry {
|
suspend fun dumpHistory(): BackupEntry {
|
||||||
@@ -103,6 +107,14 @@ class BackupRepository @Inject constructor(
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dumpReaderGridSettings(): BackupEntry {
|
||||||
|
val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray())
|
||||||
|
val settingsDump = tapGridSettings.getAllValues()
|
||||||
|
val json = JsonSerializer(settingsDump).toJson()
|
||||||
|
entry.data.put(json)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun dumpSources(): BackupEntry {
|
suspend fun dumpSources(): BackupEntry {
|
||||||
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
||||||
val all = db.getSourcesDao().findAll()
|
val all = db.getSourcesDao().findAll()
|
||||||
@@ -128,9 +140,11 @@ class BackupRepository @Inject constructor(
|
|||||||
return if (timestamp == 0L) null else Date(timestamp)
|
return if (timestamp == 0L) null else Date(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
val list = entry.data.asTypedList<JSONObject>()
|
||||||
|
outProgress?.emit(Progress(progress = 0, total = list.size))
|
||||||
|
for ((index, item) in list.withIndex()) {
|
||||||
val mangaJson = item.getJSONObject("manga")
|
val mangaJson = item.getJSONObject("manga")
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||||
@@ -144,6 +158,7 @@ class BackupRepository @Inject constructor(
|
|||||||
db.getHistoryDao().upsert(history)
|
db.getHistoryDao().upsert(history)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
outProgress?.emit(Progress(progress = index, total = list.size))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -159,9 +174,11 @@ class BackupRepository @Inject constructor(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
val list = entry.data.asTypedList<JSONObject>()
|
||||||
|
outProgress?.emit(Progress(progress = 0, total = list.size))
|
||||||
|
for ((index, item) in list.withIndex()) {
|
||||||
val mangaJson = item.getJSONObject("manga")
|
val mangaJson = item.getJSONObject("manga")
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||||
@@ -175,6 +192,7 @@ class BackupRepository @Inject constructor(
|
|||||||
db.getFavouritesDao().upsert(favourite)
|
db.getFavouritesDao().upsert(favourite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
outProgress?.emit(Progress(progress = index, total = list.size))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -221,4 +239,14 @@ class BackupRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
|
||||||
|
val result = CompositeResult()
|
||||||
|
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||||
|
result += runCatchingCancellable {
|
||||||
|
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.Closeable
|
import okio.Closeable
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.LocalDate
|
import java.text.ParseException
|
||||||
import java.time.format.DateTimeFormatter
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.zip.Deflater
|
import java.util.zip.Deflater
|
||||||
|
|
||||||
@@ -27,20 +29,32 @@ class BackupZipOutput(val file: File) : Closeable {
|
|||||||
override fun close() {
|
override fun close() {
|
||||||
output.close()
|
output.close()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const val DIR_BACKUPS = "backups"
|
companion object {
|
||||||
|
|
||||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
const val DIR_BACKUPS = "backups"
|
||||||
val dir = context.run {
|
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
|
fun generateFileName(context: Context) = buildString {
|
||||||
|
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||||
|
append('_')
|
||||||
|
append(dateTimeFormat.format(Date()))
|
||||||
|
append(".bk.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBackupDateTime(fileName: String): Date? = try {
|
||||||
|
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||||
|
val dir = context.run {
|
||||||
|
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||||
|
}
|
||||||
|
dir.mkdirs()
|
||||||
|
BackupZipOutput(File(dir, generateFileName(context)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class CompositeResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
operator fun plusAssign(error: Throwable) {
|
||||||
|
errors.add(error)
|
||||||
|
}
|
||||||
|
|
||||||
operator fun plusAssign(other: CompositeResult) {
|
operator fun plusAssign(other: CompositeResult) {
|
||||||
this.successCount += other.successCount
|
this.successCount += other.successCount
|
||||||
this.errors += other.errors
|
this.errors += other.errors
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
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 ->
|
||||||
|
BackupZipOutput.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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,15 +28,16 @@ class JsonDeserializer(private val json: JSONObject) {
|
|||||||
fun toMangaEntity() = MangaEntity(
|
fun toMangaEntity() = MangaEntity(
|
||||||
id = json.getLong("id"),
|
id = json.getLong("id"),
|
||||||
title = json.getString("title"),
|
title = json.getString("title"),
|
||||||
altTitle = json.getStringOrNull("alt_title"),
|
altTitles = json.getStringOrNull("alt_title"),
|
||||||
url = json.getString("url"),
|
url = json.getString("url"),
|
||||||
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
||||||
rating = json.getDouble("rating").toFloat(),
|
rating = json.getDouble("rating").toFloat(),
|
||||||
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
||||||
|
contentRating = json.getStringOrNull("content_rating"),
|
||||||
coverUrl = json.getString("cover_url"),
|
coverUrl = json.getString("cover_url"),
|
||||||
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
||||||
state = json.getStringOrNull("state"),
|
state = json.getStringOrNull("state"),
|
||||||
author = json.getStringOrNull("author"),
|
authors = json.getStringOrNull("author"),
|
||||||
source = json.getString("source"),
|
source = json.getString("source"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -58,15 +58,16 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
|||||||
JSONObject().apply {
|
JSONObject().apply {
|
||||||
put("id", e.id)
|
put("id", e.id)
|
||||||
put("title", e.title)
|
put("title", e.title)
|
||||||
put("alt_title", e.altTitle)
|
put("alt_title", e.altTitles)
|
||||||
put("url", e.url)
|
put("url", e.url)
|
||||||
put("public_url", e.publicUrl)
|
put("public_url", e.publicUrl)
|
||||||
put("rating", e.rating)
|
put("rating", e.rating)
|
||||||
put("nsfw", e.isNsfw)
|
put("nsfw", e.isNsfw)
|
||||||
|
put("content_rating", e.contentRating)
|
||||||
put("cover_url", e.coverUrl)
|
put("cover_url", e.coverUrl)
|
||||||
put("large_cover_url", e.largeCoverUrl)
|
put("large_cover_url", e.largeCoverUrl)
|
||||||
put("state", e.state)
|
put("state", e.state)
|
||||||
put("author", e.author)
|
put("author", e.authors)
|
||||||
put("source", e.source)
|
put("source", e.source)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.cache
|
package org.koitharu.kotatsu.core.cache
|
||||||
|
|
||||||
import androidx.collection.LruCache
|
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
|
||||||
|
|
||||||
@@ -8,11 +9,9 @@ class ExpiringLruCache<T>(
|
|||||||
val maxSize: Int,
|
val maxSize: Int,
|
||||||
private val lifetime: Long,
|
private val lifetime: Long,
|
||||||
private val timeUnit: TimeUnit,
|
private val timeUnit: TimeUnit,
|
||||||
) : Iterable<CacheKey> {
|
) {
|
||||||
|
|
||||||
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
|
private val cache = SynchronizedSieveCache<CacheKey, ExpiringValue<T>>(maxSize)
|
||||||
|
|
||||||
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
|
|
||||||
|
|
||||||
operator fun get(key: CacheKey): T? {
|
operator fun get(key: CacheKey): T? {
|
||||||
val value = cache[key] ?: return null
|
val value = cache[key] ?: return null
|
||||||
@@ -23,7 +22,8 @@ class ExpiringLruCache<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
operator fun set(key: CacheKey, value: T) {
|
operator fun set(key: CacheKey, value: T) {
|
||||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
val value = ExpiringValue(value, lifetime, timeUnit)
|
||||||
|
cache.put(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
@@ -37,4 +37,8 @@ class ExpiringLruCache<T>(
|
|||||||
fun remove(key: CacheKey) {
|
fun remove(key: CacheKey) {
|
||||||
cache.remove(key)
|
cache.remove(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun removeAll(source: MangaSource) {
|
||||||
|
cache.removeIf { key, _ -> key.source == source }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,11 +81,7 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
||||||
cache.forEach { key ->
|
cache.removeAll(source)
|
||||||
if (key.source == source) {
|
|
||||||
cache.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Key(
|
data class Key(
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
|
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
|
||||||
|
import org.koitharu.kotatsu.core.db.dao.ChaptersDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.MangaDao
|
import org.koitharu.kotatsu.core.db.dao.MangaDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
|
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
|
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.TagsDao
|
import org.koitharu.kotatsu.core.db.dao.TagsDao
|
||||||
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
|
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
@@ -36,6 +38,9 @@ import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration24To25
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||||
@@ -63,14 +68,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||||
|
|
||||||
const val DATABASE_VERSION = 23
|
const val DATABASE_VERSION = 25
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, ChapterEntity::class,
|
||||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class,
|
||||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class,
|
||||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION,
|
version = DATABASE_VERSION,
|
||||||
)
|
)
|
||||||
@@ -103,6 +108,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
abstract fun getStatsDao(): StatsDao
|
abstract fun getStatsDao(): StatsDao
|
||||||
|
|
||||||
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
||||||
|
|
||||||
|
abstract fun getChaptersDao(): ChaptersDao
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||||
@@ -128,6 +135,9 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration20To21(),
|
Migration20To21(),
|
||||||
Migration21To22(),
|
Migration21To22(),
|
||||||
Migration22To23(),
|
Migration22To23(),
|
||||||
|
Migration23To24(),
|
||||||
|
Migration24To23(),
|
||||||
|
Migration24To25(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
|
|||||||
const val TABLE_HISTORY = "history"
|
const val TABLE_HISTORY = "history"
|
||||||
const val TABLE_MANGA_TAGS = "manga_tags"
|
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||||
const val TABLE_SOURCES = "sources"
|
const val TABLE_SOURCES = "sources"
|
||||||
|
const val TABLE_CHAPTERS = "chapters"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class ChaptersDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM chapters WHERE manga_id = :mangaId ORDER BY `index` ASC")
|
||||||
|
abstract suspend fun findAll(mangaId: Long): List<ChapterEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM chapters WHERE manga_id = :mangaId")
|
||||||
|
abstract suspend fun deleteAll(mangaId: Long)
|
||||||
|
|
||||||
|
@Query("DELETE FROM chapters WHERE manga_id NOT IN (SELECT manga_id FROM history WHERE deleted_at = 0) AND manga_id NOT IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
|
||||||
|
abstract suspend fun gc()
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun replaceAll(mangaId: Long, entities: Collection<ChapterEntity>) {
|
||||||
|
deleteAll(mangaId)
|
||||||
|
insert(entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
protected abstract suspend fun insert(entities: Collection<ChapterEntity>)
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ abstract class MangaDao {
|
|||||||
@Query("SELECT * FROM manga WHERE manga_id = :id")
|
@Query("SELECT * FROM manga WHERE manga_id = :id")
|
||||||
abstract suspend fun find(id: Long): MangaWithTags?
|
abstract suspend fun find(id: Long): MangaWithTags?
|
||||||
|
|
||||||
|
@Query("SELECT EXISTS(SELECT * FROM manga WHERE manga_id = :id)")
|
||||||
|
abstract suspend operator fun contains(id: Long): Boolean
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
||||||
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
||||||
@@ -55,6 +58,19 @@ abstract class MangaDao {
|
|||||||
@Delete
|
@Delete
|
||||||
abstract suspend fun delete(subjects: Collection<MangaEntity>)
|
abstract suspend fun delete(subjects: Collection<MangaEntity>)
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
DELETE FROM manga WHERE NOT EXISTS(SELECT * FROM history WHERE history.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM favourites WHERE favourites.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM bookmarks WHERE bookmarks.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM suggestions WHERE suggestions.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM scrobblings WHERE scrobblings.manga_id == manga.manga_id)
|
||||||
|
AND NOT EXISTS(SELECT * FROM local_index WHERE local_index.manga_id == manga.manga_id)
|
||||||
|
AND manga.manga_id NOT IN (:idsToKeep)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
abstract suspend fun cleanup(idsToKeep: Set<Long>)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
||||||
upsert(manga)
|
upsert(manga)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.room.Upsert
|
|||||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.intellij.lang.annotations.Language
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||||
@@ -61,21 +60,11 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("SELECT * FROM sources WHERE pinned = 1")
|
@Query("SELECT * FROM sources WHERE pinned = 1")
|
||||||
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
||||||
|
|
||||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
|
||||||
val orderBy = getOrderBy(order)
|
observeImpl(getQuery(enabledOnly, order))
|
||||||
|
|
||||||
@Language("RoomSql")
|
suspend fun findAll(enabledOnly: Boolean, order: SourcesSortOrder): List<MangaSourceEntity> =
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
findAllImpl(getQuery(enabledOnly, order))
|
||||||
return observeImpl(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
|
|
||||||
val orderBy = getOrderBy(order)
|
|
||||||
|
|
||||||
@Language("RoomSql")
|
|
||||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
|
||||||
return findAllImpl(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
||||||
@@ -101,6 +90,17 @@ abstract class MangaSourcesDao {
|
|||||||
@RawQuery
|
@RawQuery
|
||||||
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
|
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery(
|
||||||
|
buildString {
|
||||||
|
append("SELECT * FROM sources ")
|
||||||
|
if (enabledOnly) {
|
||||||
|
append("WHERE enabled = 1 ")
|
||||||
|
}
|
||||||
|
append("ORDER BY pinned DESC, ")
|
||||||
|
append(getOrderBy(order))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
|
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
|
||||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import org.koitharu.kotatsu.core.db.TABLE_CHAPTERS
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = TABLE_CHAPTERS,
|
||||||
|
primaryKeys = ["manga_id", "chapter_id"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = MangaEntity::class,
|
||||||
|
parentColumns = ["manga_id"],
|
||||||
|
childColumns = ["manga_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class ChapterEntity(
|
||||||
|
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||||
|
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||||
|
@ColumnInfo(name = "name") val title: String,
|
||||||
|
@ColumnInfo(name = "number") val number: Float,
|
||||||
|
@ColumnInfo(name = "volume") val volume: Int,
|
||||||
|
@ColumnInfo(name = "url") val url: String,
|
||||||
|
@ColumnInfo(name = "scanlator") val scanlator: String?,
|
||||||
|
@ColumnInfo(name = "upload_date") val uploadDate: Long,
|
||||||
|
@ColumnInfo(name = "branch") val branch: String?,
|
||||||
|
@ColumnInfo(name = "source") val source: String,
|
||||||
|
@ColumnInfo(name = "index") val index: Int,
|
||||||
|
)
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
package org.koitharu.kotatsu.core.db.entity
|
package org.koitharu.kotatsu.core.db.entity
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toArraySet
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
|
||||||
|
private const val VALUES_DIVIDER = '\n'
|
||||||
|
|
||||||
// Entity to model
|
// Entity to model
|
||||||
|
|
||||||
fun TagEntity.toMangaTag() = MangaTag(
|
fun TagEntity.toMangaTag() = MangaTag(
|
||||||
@@ -21,26 +27,42 @@ fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
|
|||||||
|
|
||||||
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
|
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
|
||||||
|
|
||||||
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
fun MangaEntity.toManga(tags: Set<MangaTag>, chapters: List<ChapterEntity>?) = Manga(
|
||||||
id = this.id,
|
id = this.id,
|
||||||
title = this.title,
|
title = this.title,
|
||||||
altTitle = this.altTitle,
|
altTitles = this.altTitles?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
|
||||||
state = this.state?.let { MangaState(it) },
|
state = this.state?.let { MangaState(it) },
|
||||||
rating = this.rating,
|
rating = this.rating,
|
||||||
isNsfw = this.isNsfw,
|
contentRating = ContentRating(this.contentRating)
|
||||||
|
?: if (isNsfw) ContentRating.ADULT else null,
|
||||||
url = this.url,
|
url = this.url,
|
||||||
publicUrl = this.publicUrl,
|
publicUrl = this.publicUrl,
|
||||||
coverUrl = this.coverUrl,
|
coverUrl = this.coverUrl,
|
||||||
largeCoverUrl = this.largeCoverUrl,
|
largeCoverUrl = this.largeCoverUrl,
|
||||||
author = this.author,
|
authors = this.authors?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
|
||||||
source = MangaSource(this.source),
|
source = MangaSource(this.source),
|
||||||
tags = tags,
|
tags = tags,
|
||||||
|
chapters = chapters?.toMangaChapters(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
fun MangaWithTags.toManga(chapters: List<ChapterEntity>? = null) = manga.toManga(tags.toMangaTags(), chapters)
|
||||||
|
|
||||||
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
|
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
|
||||||
|
|
||||||
|
fun ChapterEntity.toMangaChapter() = MangaChapter(
|
||||||
|
id = chapterId,
|
||||||
|
title = title.nullIfEmpty(),
|
||||||
|
number = number,
|
||||||
|
volume = volume,
|
||||||
|
url = url,
|
||||||
|
scanlator = scanlator,
|
||||||
|
uploadDate = uploadDate,
|
||||||
|
branch = branch,
|
||||||
|
source = MangaSource(source),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Collection<ChapterEntity>.toMangaChapters() = map { it.toMangaChapter() }
|
||||||
|
|
||||||
// Model to entity
|
// Model to entity
|
||||||
|
|
||||||
fun Manga.toEntity() = MangaEntity(
|
fun Manga.toEntity() = MangaEntity(
|
||||||
@@ -49,13 +71,14 @@ fun Manga.toEntity() = MangaEntity(
|
|||||||
publicUrl = publicUrl,
|
publicUrl = publicUrl,
|
||||||
source = source.name,
|
source = source.name,
|
||||||
largeCoverUrl = largeCoverUrl,
|
largeCoverUrl = largeCoverUrl,
|
||||||
coverUrl = coverUrl,
|
coverUrl = coverUrl.orEmpty(),
|
||||||
altTitle = altTitle,
|
altTitles = altTitles.joinToString(VALUES_DIVIDER.toString()),
|
||||||
rating = rating,
|
rating = rating,
|
||||||
isNsfw = isNsfw,
|
isNsfw = isNsfw,
|
||||||
|
contentRating = contentRating?.name,
|
||||||
state = state?.name,
|
state = state?.name,
|
||||||
title = title,
|
title = title,
|
||||||
author = author,
|
authors = authors.joinToString(VALUES_DIVIDER.toString()),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaTag.toEntity() = TagEntity(
|
fun MangaTag.toEntity() = TagEntity(
|
||||||
@@ -67,6 +90,22 @@ fun MangaTag.toEntity() = TagEntity(
|
|||||||
|
|
||||||
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||||
|
|
||||||
|
fun Iterable<IndexedValue<MangaChapter>>.toEntities(mangaId: Long) = map { (index, chapter) ->
|
||||||
|
ChapterEntity(
|
||||||
|
chapterId = chapter.id,
|
||||||
|
mangaId = mangaId,
|
||||||
|
title = chapter.title.orEmpty(),
|
||||||
|
number = chapter.number,
|
||||||
|
volume = chapter.volume,
|
||||||
|
url = chapter.url,
|
||||||
|
scanlator = chapter.scanlator,
|
||||||
|
uploadDate = chapter.uploadDate,
|
||||||
|
branch = chapter.branch,
|
||||||
|
source = chapter.source.name,
|
||||||
|
index = index,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Other
|
// Other
|
||||||
|
|
||||||
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||||
@@ -76,3 +115,7 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
|||||||
fun MangaState(name: String): MangaState? = runCatching {
|
fun MangaState(name: String): MangaState? = runCatching {
|
||||||
MangaState.valueOf(name)
|
MangaState.valueOf(name)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun ContentRating(name: String?): ContentRating? = runCatching {
|
||||||
|
ContentRating.valueOf(name ?: return@runCatching null)
|
||||||
|
}.getOrNull()
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ data class MangaEntity(
|
|||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@ColumnInfo(name = "manga_id") val id: Long,
|
@ColumnInfo(name = "manga_id") val id: Long,
|
||||||
@ColumnInfo(name = "title") val title: String,
|
@ColumnInfo(name = "title") val title: String,
|
||||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
@ColumnInfo(name = "alt_title") val altTitles: String?,
|
||||||
@ColumnInfo(name = "url") val url: String,
|
@ColumnInfo(name = "url") val url: String,
|
||||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||||
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||||
|
@ColumnInfo(name = "content_rating") val contentRating: String?,
|
||||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||||
@ColumnInfo(name = "state") val state: String?,
|
@ColumnInfo(name = "state") val state: String?,
|
||||||
@ColumnInfo(name = "author") val author: String?,
|
@ColumnInfo(name = "author") val authors: String?,
|
||||||
@ColumnInfo(name = "source") val source: String,
|
@ColumnInfo(name = "source") val source: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration23To24 : Migration(23, 24) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("CREATE TABLE IF NOT EXISTS `chapters` (`chapter_id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` REAL NOT NULL, `volume` INTEGER NOT NULL, `url` TEXT NOT NULL, `scanlator` TEXT, `upload_date` INTEGER NOT NULL, `branch` TEXT, `source` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `chapter_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration24To23 : Migration(24, 23) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("DROP TABLE IF EXISTS `chapters`")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration24To25 : Migration(24, 25) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE manga ADD COLUMN content_rating TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("UPDATE manga SET content_rating = 'ADULT' WHERE nsfw = 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
class CaughtException(cause: Throwable, override val message: String?) : RuntimeException(cause)
|
class CaughtException(
|
||||||
|
override val cause: Throwable
|
||||||
|
) : RuntimeException("${cause.javaClass.simpleName}(${cause.message})", cause)
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.exceptions
|
|||||||
import okio.IOException
|
import okio.IOException
|
||||||
|
|
||||||
class NoDataReceivedException(
|
class NoDataReceivedException(
|
||||||
url: String,
|
val url: String,
|
||||||
) : IOException("No data has been received from $url")
|
) : IOException("No data has been received from $url")
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
|
||||||
|
class WrapperIOException(override val cause: Exception) : IOException(cause)
|
||||||
@@ -6,7 +6,6 @@ import androidx.core.util.Consumer
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
@@ -32,10 +31,10 @@ class DialogErrorObserver(
|
|||||||
if (canResolve(value)) {
|
if (canResolve(value)) {
|
||||||
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
||||||
} else if (value is ParseException) {
|
} else if (value is ParseException) {
|
||||||
val fm = fragmentManager
|
val router = router()
|
||||||
if (fm != null && value.isSerializable()) {
|
if (router != null && value.isSerializable()) {
|
||||||
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
|
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
|
||||||
ErrorDetailsDialog.show(fm, value, value.url)
|
router.showErrorDialog(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.view.View
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.util.Consumer
|
import androidx.core.util.Consumer
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import androidx.lifecycle.LifecycleCoroutineScope
|
import androidx.lifecycle.LifecycleCoroutineScope
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
@@ -11,6 +12,7 @@ import androidx.lifecycle.coroutineScope
|
|||||||
import kotlinx.coroutines.flow.FlowCollector
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ abstract class ErrorObserver(
|
|||||||
return resolver != null && ExceptionResolver.canResolve(error)
|
return resolver != null && ExceptionResolver.canResolve(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected fun router() = fragment?.router ?: (activity as? FragmentActivity)?.router
|
||||||
|
|
||||||
private fun isAlive(): Boolean {
|
private fun isAlive(): Boolean {
|
||||||
return when {
|
return when {
|
||||||
fragment != null -> fragment.view != null
|
fragment != null -> fragment.view != null
|
||||||
@@ -44,7 +48,7 @@ abstract class ErrorObserver(
|
|||||||
protected fun resolve(error: Throwable) {
|
protected fun resolve(error: Throwable) {
|
||||||
if (isAlive()) {
|
if (isAlive()) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val isResolved = resolver?.resolve(error) ?: false
|
val isResolved = resolver?.resolve(error) == true
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
onResolved?.accept(isResolved)
|
onResolved?.accept(isResolved)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import android.widget.Toast
|
|||||||
import androidx.activity.result.ActivityResultCaller
|
import androidx.activity.result.ActivityResultCaller
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.MutableScatterMap
|
import androidx.collection.MutableScatterMap
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
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.dialog.ErrorDetailsDialog
|
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
@@ -26,7 +27,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
|
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
|
||||||
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
||||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
import java.security.cert.CertPathValidatorException
|
import java.security.cert.CertPathValidatorException
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
@@ -49,8 +49,8 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showDetails(e: Throwable, url: String?) {
|
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||||
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
|
host.router()?.showErrorDialog(e, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||||
@@ -63,9 +63,7 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is ProxyConfigException -> {
|
is ProxyConfigException -> {
|
||||||
host.withContext {
|
host.router()?.openProxySettings()
|
||||||
startActivity(SettingsActivity.newProxySettingsIntent(this))
|
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +83,7 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
host.withContext {
|
host.withContext {
|
||||||
authHelper.startAuth(this, e.scrobbler).onFailure {
|
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||||
showDetails(it, null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -106,12 +102,12 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
sourceAuthContract.launch(source)
|
sourceAuthContract.launch(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openInBrowser(url: String) = host.withContext {
|
private fun openInBrowser(url: String) {
|
||||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
host.router()?.openBrowser(url, null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openAlternatives(manga: Manga) = host.withContext {
|
private fun openAlternatives(manga: Manga) {
|
||||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
host.router()?.openAlternatives(manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||||
@@ -140,6 +136,12 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
getContext()?.apply(block)
|
getContext()?.apply(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Host.router(): AppRouter? = when (this) {
|
||||||
|
is FragmentActivity -> router
|
||||||
|
is Fragment -> router
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
interface Host : ActivityResultCaller {
|
interface Host : ActivityResultCaller {
|
||||||
|
|
||||||
fun getChildFragmentManager(): FragmentManager
|
fun getChildFragmentManager(): FragmentManager
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.core.util.Consumer
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||||
@@ -33,10 +32,10 @@ class SnackbarErrorObserver(
|
|||||||
resolve(value)
|
resolve(value)
|
||||||
}
|
}
|
||||||
} else if (value is ParseException) {
|
} else if (value is ParseException) {
|
||||||
val fm = fragmentManager
|
val router = router()
|
||||||
if (fm != null && value.isSerializable()) {
|
if (router != null && value.isSerializable()) {
|
||||||
snackbar.setAction(R.string.details) {
|
snackbar.setAction(R.string.details) {
|
||||||
ErrorDetailsDialog.show(fm, value, value.url)
|
router.showErrorDialog(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.github
|
package org.koitharu.kotatsu.core.github
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -9,6 +11,7 @@ import okhttp3.Request
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||||
import org.koitharu.kotatsu.core.os.AppValidator
|
import org.koitharu.kotatsu.core.os.AppValidator
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
@@ -18,26 +21,37 @@ import org.koitharu.kotatsu.parsers.util.await
|
|||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
|
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
|
||||||
import org.koitharu.kotatsu.parsers.util.parseJsonArray
|
import org.koitharu.kotatsu.parsers.util.parseJsonArray
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
|
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
|
||||||
|
private const val BUILD_TYPE_RELEASE = "release"
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class AppUpdateRepository @Inject constructor(
|
class AppUpdateRepository @Inject constructor(
|
||||||
private val appValidator: AppValidator,
|
private val appValidator: AppValidator,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
@BaseHttpClient private val okHttp: OkHttpClient,
|
@BaseHttpClient private val okHttp: OkHttpClient,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val availableUpdate = MutableStateFlow<AppVersion?>(null)
|
private val availableUpdate = MutableStateFlow<AppVersion?>(null)
|
||||||
|
private val releasesUrl = buildString {
|
||||||
|
append("https://api.github.com/repos/")
|
||||||
|
append(context.getString(R.string.github_updates_repo))
|
||||||
|
append("/releases?page=1&per_page=10")
|
||||||
|
}
|
||||||
|
|
||||||
|
val isUpdateAvailable: Boolean
|
||||||
|
get() = availableUpdate.value != null
|
||||||
|
|
||||||
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
|
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
|
||||||
|
|
||||||
suspend fun getAvailableVersions(): List<AppVersion> {
|
suspend fun getAvailableVersions(): List<AppVersion> {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.get()
|
.get()
|
||||||
.url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases?page=1&per_page=10")
|
.url(releasesUrl)
|
||||||
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
|
val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray()
|
||||||
return jsonArray.mapJSONNotNull { json ->
|
return jsonArray.mapJSONNotNull { json ->
|
||||||
val asset = json.optJSONArray("assets")?.find { jo ->
|
val asset = json.optJSONArray("assets")?.find { jo ->
|
||||||
@@ -74,8 +88,9 @@ class AppUpdateRepository @Inject constructor(
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isUpdateSupported(): Boolean {
|
@Suppress("KotlinConstantConditions")
|
||||||
return BuildConfig.DEBUG || appValidator.isOriginalApp
|
suspend fun isUpdateSupported(): Boolean {
|
||||||
|
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp.getOrNull() == true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCurrentVersionChangelog(): String? {
|
suspend fun getCurrentVersionChangelog(): String? {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.github
|
package org.koitharu.kotatsu.core.github
|
||||||
|
|
||||||
import java.util.*
|
import org.koitharu.kotatsu.parsers.util.digits
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
data class VersionId(
|
data class VersionId(
|
||||||
val major: Int,
|
val major: Int,
|
||||||
@@ -43,6 +44,16 @@ val VersionId.isStable: Boolean
|
|||||||
get() = variantType.isEmpty()
|
get() = variantType.isEmpty()
|
||||||
|
|
||||||
fun VersionId(versionName: String): VersionId {
|
fun VersionId(versionName: String): VersionId {
|
||||||
|
if (versionName.startsWith('n', ignoreCase = true)) {
|
||||||
|
// Nightly build
|
||||||
|
return VersionId(
|
||||||
|
major = 0,
|
||||||
|
minor = 0,
|
||||||
|
build = versionName.digits().toIntOrNull() ?: 0,
|
||||||
|
variantType = "n",
|
||||||
|
variantNumber = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
val parts = versionName.substringBeforeLast('-').split('.')
|
val parts = versionName.substringBeforeLast('-').split('.')
|
||||||
val variant = versionName.substringAfterLast('-', "")
|
val variant = versionName.substringAfterLast('-', "")
|
||||||
return VersionId(
|
return VersionId(
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
package org.koitharu.kotatsu.core.image
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import coil3.ImageLoader
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
import coil3.asImage
|
||||||
import coil.ImageLoader
|
import coil3.decode.DecodeResult
|
||||||
import coil.decode.DecodeResult
|
import coil3.decode.Decoder
|
||||||
import coil.decode.Decoder
|
import coil3.decode.ImageSource
|
||||||
import coil.decode.ImageSource
|
import coil3.fetch.SourceFetchResult
|
||||||
import coil.fetch.SourceResult
|
import coil3.request.Options
|
||||||
import coil.request.Options
|
|
||||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.runInterruptible
|
||||||
import org.aomedia.avif.android.AvifDecoder
|
import org.aomedia.avif.android.AvifDecoder
|
||||||
import org.aomedia.avif.android.AvifDecoder.Info
|
import org.aomedia.avif.android.AvifDecoder.Info
|
||||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||||
|
|
||||||
class AvifImageDecoder(source: ImageSource, options: Options, parallelismLock: Semaphore) :
|
class AvifImageDecoder(
|
||||||
BaseCoilDecoder(source, options, parallelismLock) {
|
private val source: ImageSource,
|
||||||
|
private val options: Options,
|
||||||
|
) : Decoder {
|
||||||
|
|
||||||
override fun BitmapFactory.Options.decode(): DecodeResult {
|
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||||
val bytes = source.source().use {
|
val bytes = source.source().use {
|
||||||
it.inputStream().toByteBuffer()
|
it.inputStream().toByteBuffer()
|
||||||
}
|
}
|
||||||
@@ -36,22 +37,20 @@ class AvifImageDecoder(source: ImageSource, options: Options, parallelismLock: S
|
|||||||
bitmap.recycle()
|
bitmap.recycle()
|
||||||
throw ImageDecodeException(null, "avif")
|
throw ImageDecodeException(null, "avif")
|
||||||
}
|
}
|
||||||
return DecodeResult(
|
DecodeResult(
|
||||||
drawable = bitmap.toDrawable(options.context.resources),
|
image = bitmap.asImage(),
|
||||||
isSampled = false,
|
isSampled = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory : Decoder.Factory {
|
class Factory : Decoder.Factory {
|
||||||
|
|
||||||
private val parallelismLock = Semaphore(DEFAULT_PARALLELISM)
|
|
||||||
|
|
||||||
override fun create(
|
override fun create(
|
||||||
result: SourceResult,
|
result: SourceFetchResult,
|
||||||
options: Options,
|
options: Options,
|
||||||
imageLoader: ImageLoader
|
imageLoader: ImageLoader
|
||||||
): Decoder? = if (isApplicable(result)) {
|
): Decoder? = if (isApplicable(result)) {
|
||||||
AvifImageDecoder(result.source, options, parallelismLock)
|
AvifImageDecoder(result.source, options)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -60,7 +59,7 @@ class AvifImageDecoder(source: ImageSource, options: Options, parallelismLock: S
|
|||||||
|
|
||||||
override fun hashCode() = javaClass.hashCode()
|
override fun hashCode() = javaClass.hashCode()
|
||||||
|
|
||||||
private fun isApplicable(result: SourceResult): Boolean {
|
private fun isApplicable(result: SourceFetchResult): Boolean {
|
||||||
return result.mimeType == "image/avif"
|
return result.mimeType == "image/avif"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.image
|
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import coil.decode.DecodeResult
|
|
||||||
import coil.decode.Decoder
|
|
||||||
import coil.decode.ImageSource
|
|
||||||
import coil.request.Options
|
|
||||||
import coil.size.Dimension
|
|
||||||
import coil.size.Scale
|
|
||||||
import coil.size.Size
|
|
||||||
import coil.size.isOriginal
|
|
||||||
import coil.size.pxOrElse
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
import org.jetbrains.annotations.Blocking
|
|
||||||
|
|
||||||
abstract class BaseCoilDecoder(
|
|
||||||
protected val source: ImageSource,
|
|
||||||
protected val options: Options,
|
|
||||||
private val parallelismLock: Semaphore,
|
|
||||||
) : Decoder {
|
|
||||||
|
|
||||||
final override suspend fun decode(): DecodeResult = parallelismLock.withPermit {
|
|
||||||
runInterruptible { BitmapFactory.Options().decode() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Blocking
|
|
||||||
protected abstract fun BitmapFactory.Options.decode(): DecodeResult
|
|
||||||
|
|
||||||
protected companion object {
|
|
||||||
|
|
||||||
const val DEFAULT_PARALLELISM = 4
|
|
||||||
|
|
||||||
inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
|
|
||||||
return if (isOriginal) original() else width.toPx(scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
|
|
||||||
return if (isOriginal) original() else height.toPx(scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Dimension.toPx(scale: Scale) = pxOrElse {
|
|
||||||
when (scale) {
|
|
||||||
Scale.FILL -> Int.MIN_VALUE
|
|
||||||
Scale.FIT -> Int.MAX_VALUE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,25 +4,26 @@ import android.graphics.Bitmap
|
|||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.ImageDecoder
|
import android.graphics.ImageDecoder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.webkit.MimeTypeMap
|
import androidx.annotation.RequiresApi
|
||||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||||
import okhttp3.MediaType
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import org.aomedia.avif.android.AvifDecoder
|
import org.aomedia.avif.android.AvifDecoder
|
||||||
import org.aomedia.avif.android.AvifDecoder.Info
|
import org.aomedia.avif.android.AvifDecoder.Info
|
||||||
import org.jetbrains.annotations.Blocking
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.MimeType
|
||||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.file.Files
|
|
||||||
|
|
||||||
object BitmapDecoderCompat {
|
object BitmapDecoderCompat {
|
||||||
|
|
||||||
private const val FORMAT_AVIF = "avif"
|
private const val FORMAT_AVIF = "avif"
|
||||||
|
|
||||||
@Blocking
|
@Blocking
|
||||||
fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) {
|
fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) {
|
||||||
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
|
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
|
||||||
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
|
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
|
||||||
@@ -32,28 +33,38 @@ object BitmapDecoderCompat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Blocking
|
@Blocking
|
||||||
fun decode(stream: InputStream, type: MediaType?): Bitmap {
|
fun decode(stream: InputStream, type: MimeType?, isMutable: Boolean = false): Bitmap {
|
||||||
val format = type?.subtype
|
val format = type?.subtype
|
||||||
if (format == FORMAT_AVIF) {
|
if (format == FORMAT_AVIF) {
|
||||||
return decodeAvif(stream.toByteBuffer())
|
return decodeAvif(stream.toByteBuffer())
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
return checkBitmapNotNull(BitmapFactory.decodeStream(stream), format)
|
val opts = BitmapFactory.Options()
|
||||||
|
opts.inMutable = isMutable
|
||||||
|
return checkBitmapNotNull(BitmapFactory.decodeStream(stream, null, opts), format)
|
||||||
}
|
}
|
||||||
val byteBuffer = stream.toByteBuffer()
|
val byteBuffer = stream.toByteBuffer()
|
||||||
return if (AvifDecoder.isAvifImage(byteBuffer)) {
|
return if (AvifDecoder.isAvifImage(byteBuffer)) {
|
||||||
decodeAvif(byteBuffer)
|
decodeAvif(byteBuffer)
|
||||||
} else {
|
} else {
|
||||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer))
|
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer), DecoderConfigListener(isMutable))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
@Blocking
|
||||||
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
|
fun probeMimeType(file: File): MimeType? {
|
||||||
} else {
|
return MimeTypes.probeMimeType(file) ?: detectBitmapType(file)
|
||||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
private fun detectBitmapType(file: File): MimeType? = runCatchingCancellable {
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeFile(file.path, options)?.recycle()
|
||||||
|
return options.outMimeType?.toMimeTypeOrNull()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
|
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
|
||||||
bitmap ?: throw ImageDecodeException(null, format)
|
bitmap ?: throw ImageDecodeException(null, format)
|
||||||
|
|
||||||
@@ -74,4 +85,18 @@ object BitmapDecoderCompat {
|
|||||||
}
|
}
|
||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
|
private class DecoderConfigListener(
|
||||||
|
private val isMutable: Boolean,
|
||||||
|
) : ImageDecoder.OnHeaderDecodedListener {
|
||||||
|
|
||||||
|
override fun onHeaderDecoded(
|
||||||
|
decoder: ImageDecoder,
|
||||||
|
info: ImageDecoder.ImageInfo,
|
||||||
|
source: ImageDecoder.Source
|
||||||
|
) {
|
||||||
|
decoder.isMutableRequired = isMutable
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.decode.DataSource
|
||||||
|
import coil3.decode.ImageSource
|
||||||
|
import coil3.fetch.Fetcher
|
||||||
|
import coil3.fetch.SourceFetchResult
|
||||||
|
import coil3.request.Options
|
||||||
|
import coil3.toAndroidUri
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.Path.Companion.toPath
|
||||||
|
import okio.openZip
|
||||||
|
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isZipUri
|
||||||
|
import coil3.Uri as CoilUri
|
||||||
|
|
||||||
|
class CbzFetcher(
|
||||||
|
private val uri: Uri,
|
||||||
|
private val options: Options,
|
||||||
|
) : Fetcher {
|
||||||
|
|
||||||
|
override suspend fun fetch() = runInterruptible {
|
||||||
|
val filePath = uri.schemeSpecificPart.toPath()
|
||||||
|
val entryName = requireNotNull(uri.fragment)
|
||||||
|
val fs = options.fileSystem.openZip(filePath)
|
||||||
|
SourceFetchResult(
|
||||||
|
source = ImageSource(entryName.toPath(), fs, closeable = fs),
|
||||||
|
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
|
||||||
|
dataSource = DataSource.DISK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory : Fetcher.Factory<CoilUri> {
|
||||||
|
|
||||||
|
override fun create(
|
||||||
|
data: CoilUri,
|
||||||
|
options: Options,
|
||||||
|
imageLoader: ImageLoader
|
||||||
|
): Fetcher? {
|
||||||
|
val androidUri = data.toAndroidUri()
|
||||||
|
return if (androidUri.isZipUri()) {
|
||||||
|
CbzFetcher(androidUri, options)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import coil3.intercept.Interceptor
|
||||||
|
import coil3.network.httpHeaders
|
||||||
|
import coil3.request.ImageResult
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
|
||||||
|
class MangaSourceHeaderInterceptor : Interceptor {
|
||||||
|
|
||||||
|
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||||
|
val mangaSource = chain.request.extras[mangaSourceKey] as? MangaParserSource ?: return chain.proceed()
|
||||||
|
val request = chain.request
|
||||||
|
val newHeaders = request.httpHeaders.newBuilder()
|
||||||
|
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
|
||||||
|
.build()
|
||||||
|
val newRequest = request.newBuilder()
|
||||||
|
.httpHeaders(newHeaders)
|
||||||
|
.build()
|
||||||
|
return chain.withRequest(newRequest).proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,22 +5,35 @@ import android.graphics.BitmapFactory
|
|||||||
import android.graphics.BitmapRegionDecoder
|
import android.graphics.BitmapRegionDecoder
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
import coil3.Extras
|
||||||
import coil.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil.decode.DecodeResult
|
import coil3.asImage
|
||||||
import coil.decode.DecodeUtils
|
import coil3.decode.DecodeResult
|
||||||
import coil.decode.Decoder
|
import coil3.decode.DecodeUtils
|
||||||
import coil.decode.ImageSource
|
import coil3.decode.Decoder
|
||||||
import coil.fetch.SourceResult
|
import coil3.decode.ImageSource
|
||||||
import coil.request.Options
|
import coil3.fetch.SourceFetchResult
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import coil3.getExtra
|
||||||
|
import coil3.request.Options
|
||||||
|
import coil3.request.allowRgb565
|
||||||
|
import coil3.request.bitmapConfig
|
||||||
|
import coil3.request.colorSpace
|
||||||
|
import coil3.request.premultipliedAlpha
|
||||||
|
import coil3.size.Dimension
|
||||||
|
import coil3.size.Precision
|
||||||
|
import coil3.size.Scale
|
||||||
|
import coil3.size.Size
|
||||||
|
import coil3.size.isOriginal
|
||||||
|
import coil3.size.pxOrElse
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class RegionBitmapDecoder(
|
class RegionBitmapDecoder(
|
||||||
source: ImageSource, options: Options, parallelismLock: Semaphore
|
private val source: ImageSource,
|
||||||
) : BaseCoilDecoder(source, options, parallelismLock) {
|
private val options: Options,
|
||||||
|
) : Decoder {
|
||||||
|
|
||||||
override fun BitmapFactory.Options.decode(): DecodeResult {
|
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||||
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
BitmapRegionDecoder.newInstance(source.source().inputStream())
|
BitmapRegionDecoder.newInstance(source.source().inputStream())
|
||||||
} else {
|
} else {
|
||||||
@@ -28,13 +41,14 @@ class RegionBitmapDecoder(
|
|||||||
BitmapRegionDecoder.newInstance(source.source().inputStream(), false)
|
BitmapRegionDecoder.newInstance(source.source().inputStream(), false)
|
||||||
}
|
}
|
||||||
checkNotNull(regionDecoder)
|
checkNotNull(regionDecoder)
|
||||||
|
val bitmapOptions = BitmapFactory.Options()
|
||||||
try {
|
try {
|
||||||
val rect = configureScale(regionDecoder.width, regionDecoder.height)
|
val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
|
||||||
configureConfig()
|
bitmapOptions.configureConfig()
|
||||||
val bitmap = regionDecoder.decodeRegion(rect, this)
|
val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
|
||||||
bitmap.density = options.context.resources.displayMetrics.densityDpi
|
bitmap.density = options.context.resources.displayMetrics.densityDpi
|
||||||
return DecodeResult(
|
DecodeResult(
|
||||||
drawable = bitmap.toDrawable(options.context.resources),
|
image = bitmap.asImage(),
|
||||||
isSampled = true,
|
isSampled = true,
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,7 +69,7 @@ class RegionBitmapDecoder(
|
|||||||
} else {
|
} else {
|
||||||
Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight)
|
Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight)
|
||||||
}
|
}
|
||||||
val scroll = options.parameters.value(PARAM_SCROLL) ?: SCROLL_UNDEFINED
|
val scroll = options.getExtra(regionScrollKey)
|
||||||
if (scroll == SCROLL_UNDEFINED) {
|
if (scroll == SCROLL_UNDEFINED) {
|
||||||
rect.offsetTo(
|
rect.offsetTo(
|
||||||
(srcWidth - rect.width()) / 2,
|
(srcWidth - rect.width()) / 2,
|
||||||
@@ -87,7 +101,7 @@ class RegionBitmapDecoder(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Only upscale the image if the options require an exact size.
|
// Only upscale the image if the options require an exact size.
|
||||||
if (options.allowInexactSize) {
|
if (options.precision == Precision.INEXACT) {
|
||||||
scale = scale.coerceAtMost(1.0)
|
scale = scale.coerceAtMost(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +121,7 @@ class RegionBitmapDecoder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun BitmapFactory.Options.configureConfig() {
|
private fun BitmapFactory.Options.configureConfig() {
|
||||||
var config = options.config
|
var config = options.bitmapConfig
|
||||||
|
|
||||||
inMutable = false
|
inMutable = false
|
||||||
|
|
||||||
@@ -131,13 +145,11 @@ class RegionBitmapDecoder(
|
|||||||
|
|
||||||
object Factory : Decoder.Factory {
|
object Factory : Decoder.Factory {
|
||||||
|
|
||||||
private val parallelismLock = Semaphore(DEFAULT_PARALLELISM)
|
|
||||||
|
|
||||||
override fun create(
|
override fun create(
|
||||||
result: SourceResult,
|
result: SourceFetchResult,
|
||||||
options: Options,
|
options: Options,
|
||||||
imageLoader: ImageLoader
|
imageLoader: ImageLoader
|
||||||
): Decoder = RegionBitmapDecoder(result.source, options, parallelismLock)
|
): Decoder = RegionBitmapDecoder(result.source, options)
|
||||||
|
|
||||||
override fun equals(other: Any?) = other is Factory
|
override fun equals(other: Any?) = other is Factory
|
||||||
|
|
||||||
@@ -146,7 +158,22 @@ class RegionBitmapDecoder(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val PARAM_SCROLL = "scroll"
|
|
||||||
const val SCROLL_UNDEFINED = -1
|
const val SCROLL_UNDEFINED = -1
|
||||||
|
val regionScrollKey = Extras.Key(SCROLL_UNDEFINED)
|
||||||
|
|
||||||
|
private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
|
||||||
|
return if (isOriginal) original() else width.toPx(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
|
||||||
|
return if (isOriginal) original() else height.toPx(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Dimension.toPx(scale: Scale) = pxOrElse {
|
||||||
|
when (scale) {
|
||||||
|
Scale.FILL -> Int.MIN_VALUE
|
||||||
|
Scale.FIT -> Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.MutableObjectIntMap
|
import androidx.collection.MutableObjectIntMap
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.strikeThrough
|
import androidx.core.text.strikeThrough
|
||||||
@@ -17,7 +19,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.util.formatSimple
|
import org.koitharu.kotatsu.parsers.util.findById
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -29,8 +31,6 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
|||||||
@JvmName("chaptersIds")
|
@JvmName("chaptersIds")
|
||||||
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
|
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
|
||||||
|
|
||||||
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
|
|
||||||
|
|
||||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||||
if (size <= 1) {
|
if (size <= 1) {
|
||||||
return size
|
return size
|
||||||
@@ -84,10 +84,6 @@ val Demographic.titleResId: Int
|
|||||||
Demographic.NONE -> R.string.none
|
Demographic.NONE -> R.string.none
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Manga.findChapter(id: Long): MangaChapter? {
|
|
||||||
return chapters?.findById(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||||
val ch = chapters
|
val ch = chapters
|
||||||
if (ch.isNullOrEmpty()) {
|
if (ch.isNullOrEmpty()) {
|
||||||
@@ -130,18 +126,13 @@ val Manga.isBroken: Boolean
|
|||||||
get() = source == UnknownMangaSource
|
get() = source == UnknownMangaSource
|
||||||
|
|
||||||
val Manga.appUrl: Uri
|
val Manga.appUrl: Uri
|
||||||
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
get() = "https://kotatsu.app/manga".toUri()
|
||||||
|
.buildUpon()
|
||||||
.appendQueryParameter("source", source.name)
|
.appendQueryParameter("source", source.name)
|
||||||
.appendQueryParameter("name", title)
|
.appendQueryParameter("name", title)
|
||||||
.appendQueryParameter("url", url)
|
.appendQueryParameter("url", url)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
|
|
||||||
number.formatSimple()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Manga.chaptersCount(): Int {
|
fun Manga.chaptersCount(): Int {
|
||||||
if (chapters.isNullOrEmpty()) {
|
if (chapters.isNullOrEmpty()) {
|
||||||
return 0
|
return 0
|
||||||
@@ -180,3 +171,24 @@ private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MangaChapter.getLocalizedTitle(resources: Resources, index: Int = -1): String {
|
||||||
|
title?.let {
|
||||||
|
if (it.isNotBlank()) {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val num = numberString()
|
||||||
|
val vol = volumeString()
|
||||||
|
return when {
|
||||||
|
num != null && vol != null -> resources.getString(R.string.chapter_volume_number, vol, num)
|
||||||
|
num != null -> resources.getString(R.string.chapter_number, num)
|
||||||
|
index > 0 -> resources.getString(
|
||||||
|
R.string.chapters_time_pattern,
|
||||||
|
resources.getString(R.string.unnamed_chapter),
|
||||||
|
index.toString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> resources.getString(R.string.unnamed_chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package org.koitharu.kotatsu.core.model
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.style.ForegroundColorSpan
|
import android.text.style.ForegroundColorSpan
|
||||||
|
import android.text.style.ImageSpan
|
||||||
import android.text.style.RelativeSizeSpan
|
import android.text.style.RelativeSizeSpan
|
||||||
import android.text.style.SuperscriptSpan
|
import android.text.style.SuperscriptSpan
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||||
@@ -100,3 +105,16 @@ fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
|||||||
) {
|
) {
|
||||||
append(context.getString(R.string.nsfw))
|
append(context.getString(R.string.nsfw))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun SpannableStringBuilder.appendIcon(textView: TextView, @DrawableRes resId: Int): SpannableStringBuilder {
|
||||||
|
val icon = ContextCompat.getDrawable(textView.context, resId) ?: return this
|
||||||
|
icon.setTintList(textView.textColors)
|
||||||
|
val size = textView.lineHeight
|
||||||
|
icon.setBounds(0, 0, size, size)
|
||||||
|
val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
ImageSpan.ALIGN_CENTER
|
||||||
|
} else {
|
||||||
|
ImageSpan.ALIGN_BOTTOM
|
||||||
|
}
|
||||||
|
return inSpans(ImageSpan(icon, alignment)) { append(' ') }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
|
|
||||||
|
fun ListFilterOption.toChipModel(isChecked: Boolean) = ChipsView.ChipModel(
|
||||||
|
title = titleText,
|
||||||
|
titleResId = titleResId,
|
||||||
|
icon = iconResId,
|
||||||
|
iconData = getIconData(),
|
||||||
|
isChecked = isChecked,
|
||||||
|
data = this,
|
||||||
|
)
|
||||||
@@ -17,7 +17,7 @@ data class ParcelableChapter(
|
|||||||
override fun create(parcel: Parcel) = ParcelableChapter(
|
override fun create(parcel: Parcel) = ParcelableChapter(
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = parcel.readLong(),
|
id = parcel.readLong(),
|
||||||
name = parcel.readString().orEmpty(),
|
title = parcel.readString(),
|
||||||
number = parcel.readFloat(),
|
number = parcel.readFloat(),
|
||||||
volume = parcel.readInt(),
|
volume = parcel.readInt(),
|
||||||
url = parcel.readString().orEmpty(),
|
url = parcel.readString().orEmpty(),
|
||||||
@@ -30,7 +30,7 @@ data class ParcelableChapter(
|
|||||||
|
|
||||||
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
||||||
parcel.writeLong(id)
|
parcel.writeLong(id)
|
||||||
parcel.writeString(name)
|
parcel.writeString(title)
|
||||||
parcel.writeFloat(number)
|
parcel.writeFloat(number)
|
||||||
parcel.writeInt(volume)
|
parcel.writeInt(volume)
|
||||||
parcel.writeString(url)
|
parcel.writeString(url)
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ package org.koitharu.kotatsu.core.model.parcelable
|
|||||||
|
|
||||||
import android.os.Parcel
|
import android.os.Parcel
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.core.os.ParcelCompat
|
|
||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.readStringSet
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.writeStringSet
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class ParcelableManga(
|
data class ParcelableManga(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
|
private val withDescription: Boolean = true,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
companion object : Parceler<ParcelableManga> {
|
companion object : Parceler<ParcelableManga> {
|
||||||
@@ -20,17 +22,17 @@ data class ParcelableManga(
|
|||||||
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
|
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
|
||||||
parcel.writeLong(id)
|
parcel.writeLong(id)
|
||||||
parcel.writeString(title)
|
parcel.writeString(title)
|
||||||
parcel.writeString(altTitle)
|
parcel.writeStringSet(altTitles)
|
||||||
parcel.writeString(url)
|
parcel.writeString(url)
|
||||||
parcel.writeString(publicUrl)
|
parcel.writeString(publicUrl)
|
||||||
parcel.writeFloat(rating)
|
parcel.writeFloat(rating)
|
||||||
ParcelCompat.writeBoolean(parcel, isNsfw)
|
parcel.writeSerializable(contentRating)
|
||||||
parcel.writeString(coverUrl)
|
parcel.writeString(coverUrl)
|
||||||
parcel.writeString(largeCoverUrl)
|
parcel.writeString(largeCoverUrl)
|
||||||
parcel.writeString(description)
|
parcel.writeString(description.takeIf { withDescription })
|
||||||
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||||
parcel.writeSerializable(state)
|
parcel.writeSerializable(state)
|
||||||
parcel.writeString(author)
|
parcel.writeStringSet(authors)
|
||||||
parcel.writeString(source.name)
|
parcel.writeString(source.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,20 +40,21 @@ data class ParcelableManga(
|
|||||||
Manga(
|
Manga(
|
||||||
id = parcel.readLong(),
|
id = parcel.readLong(),
|
||||||
title = requireNotNull(parcel.readString()),
|
title = requireNotNull(parcel.readString()),
|
||||||
altTitle = parcel.readString(),
|
altTitles = parcel.readStringSet(),
|
||||||
url = requireNotNull(parcel.readString()),
|
url = requireNotNull(parcel.readString()),
|
||||||
publicUrl = requireNotNull(parcel.readString()),
|
publicUrl = requireNotNull(parcel.readString()),
|
||||||
rating = parcel.readFloat(),
|
rating = parcel.readFloat(),
|
||||||
isNsfw = ParcelCompat.readBoolean(parcel),
|
contentRating = parcel.readSerializableCompat(),
|
||||||
coverUrl = requireNotNull(parcel.readString()),
|
coverUrl = parcel.readString(),
|
||||||
largeCoverUrl = parcel.readString(),
|
largeCoverUrl = parcel.readString(),
|
||||||
description = parcel.readString(),
|
description = parcel.readString(),
|
||||||
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
|
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
|
||||||
state = parcel.readSerializableCompat(),
|
state = parcel.readSerializableCompat(),
|
||||||
author = parcel.readString(),
|
authors = parcel.readStringSet(),
|
||||||
chapters = null,
|
chapters = null,
|
||||||
source = MangaSource(parcel.readString()),
|
source = MangaSource(parcel.readString()),
|
||||||
),
|
),
|
||||||
|
withDescription = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ object MangaListFilterParceler : Parceler<MangaListFilter> {
|
|||||||
parcel.writeInt(year)
|
parcel.writeInt(year)
|
||||||
parcel.writeInt(yearFrom)
|
parcel.writeInt(yearFrom)
|
||||||
parcel.writeInt(yearTo)
|
parcel.writeInt(yearTo)
|
||||||
|
parcel.writeString(author)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun create(parcel: Parcel) = MangaListFilter(
|
override fun create(parcel: Parcel) = MangaListFilter(
|
||||||
@@ -45,6 +46,7 @@ object MangaListFilterParceler : Parceler<MangaListFilter> {
|
|||||||
year = parcel.readInt(),
|
year = parcel.readInt(),
|
||||||
yearFrom = parcel.readInt(),
|
yearFrom = parcel.readInt(),
|
||||||
yearTo = parcel.readInt(),
|
yearTo = parcel.readInt(),
|
||||||
|
author = parcel.readString(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
803
app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt
Normal file
803
app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
package org.koitharu.kotatsu.core.nav
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.annotation.UiContext
|
||||||
|
import androidx.core.app.ShareCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.findFragment
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||||
|
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
||||||
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||||
|
import org.koitharu.kotatsu.core.model.appUrl
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.model.isBroken
|
||||||
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
|
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
|
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
|
||||||
|
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
|
||||||
|
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoSheet
|
||||||
|
import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
|
||||||
|
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteDialog
|
||||||
|
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
|
||||||
|
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
|
||||||
|
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
|
||||||
|
import org.koitharu.kotatsu.history.ui.HistoryActivity
|
||||||
|
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||||
|
import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
|
||||||
|
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
|
||||||
|
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
|
||||||
|
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
|
||||||
|
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||||
|
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||||
|
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||||
|
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
|
||||||
|
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||||
|
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||||
|
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||||
|
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
|
||||||
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
|
import org.koitharu.kotatsu.settings.about.AppUpdateActivity
|
||||||
|
import org.koitharu.kotatsu.settings.backup.BackupDialogFragment
|
||||||
|
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
|
||||||
|
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
|
||||||
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
|
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
||||||
|
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
|
||||||
|
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||||
|
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
|
||||||
|
import org.koitharu.kotatsu.stats.ui.StatsActivity
|
||||||
|
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||||
|
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
|
||||||
|
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
|
||||||
|
import java.io.File
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class AppRouter private constructor(
|
||||||
|
private val activity: FragmentActivity?,
|
||||||
|
private val fragment: Fragment?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(activity: FragmentActivity) : this(activity, null)
|
||||||
|
|
||||||
|
constructor(fragment: Fragment) : this(null, fragment)
|
||||||
|
|
||||||
|
private val settings: AppSettings by lazy {
|
||||||
|
EntryPointAccessors.fromApplication<AppRouterEntryPoint>(checkNotNull(contextOrNull())).settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activities **/
|
||||||
|
|
||||||
|
fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) {
|
||||||
|
startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null)
|
||||||
|
|
||||||
|
fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, SearchActivity::class.java)
|
||||||
|
.putExtra(KEY_QUERY, query)
|
||||||
|
.putExtra(KEY_KIND, kind),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null)
|
||||||
|
|
||||||
|
fun openDetails(manga: Manga) {
|
||||||
|
startActivity(detailsIntent(contextOrNull() ?: return, manga))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openDetails(mangaId: Long) {
|
||||||
|
startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openDetails(link: Uri) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, DetailsActivity::class.java)
|
||||||
|
.setData(link),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openReader(manga: Manga, anchor: View? = null) {
|
||||||
|
openReader(
|
||||||
|
ReaderIntent.Builder(contextOrNull() ?: return)
|
||||||
|
.manga(manga)
|
||||||
|
.build(),
|
||||||
|
anchor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openReader(intent: ReaderIntent, anchor: View? = null) {
|
||||||
|
startActivity(intent.intent, anchor?.let { view -> scaleUpActivityOptionsOf(view) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openAlternatives(manga: Manga) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, AlternativesActivity::class.java)
|
||||||
|
.putExtra(KEY_MANGA, ParcelableManga(manga)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openRelated(manga: Manga) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull(), RelatedMangaActivity::class.java)
|
||||||
|
.putExtra(KEY_MANGA, ParcelableManga(manga)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openImage(url: String, source: MangaSource?, anchor: View? = null) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull(), ImageActivity::class.java)
|
||||||
|
.setData(url.toUri())
|
||||||
|
.putExtra(KEY_SOURCE, source?.name),
|
||||||
|
anchor?.let { scaleUpActivityOptionsOf(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openBookmarks() = startActivity(AllBookmarksActivity::class.java)
|
||||||
|
|
||||||
|
fun openAppUpdate() = startActivity(AppUpdateActivity::class.java)
|
||||||
|
|
||||||
|
fun openSuggestions() {
|
||||||
|
startActivity(suggestionsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java)
|
||||||
|
|
||||||
|
fun openDownloads() = startActivity(DownloadsActivity::class.java)
|
||||||
|
|
||||||
|
fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java)
|
||||||
|
|
||||||
|
fun openBrowser(url: String, source: MangaSource?, title: String?) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, BrowserActivity::class.java)
|
||||||
|
.setData(url.toUri())
|
||||||
|
.putExtra(KEY_TITLE, title)
|
||||||
|
.putExtra(KEY_SOURCE, source?.name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openColorFilterConfig(manga: Manga, page: MangaPage) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull(), ColorFilterConfigActivity::class.java)
|
||||||
|
.putExtra(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
.putExtra(KEY_PAGES, ParcelableMangaPage(page)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openHistory() = startActivity(HistoryActivity::class.java)
|
||||||
|
|
||||||
|
fun openFavorites() = startActivity(FavouritesActivity::class.java)
|
||||||
|
|
||||||
|
fun openFavorites(category: FavouriteCategory) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, FavouritesActivity::class.java)
|
||||||
|
.putExtra(KEY_ID, category.id)
|
||||||
|
.putExtra(KEY_TITLE, category.title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFavoriteCategories() = startActivity(FavouriteCategoriesActivity::class.java)
|
||||||
|
|
||||||
|
fun openFavoriteCategoryEdit(categoryId: Long) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, FavouritesCategoryEditActivity::class.java)
|
||||||
|
.putExtra(KEY_ID, categoryId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFavoriteCategoryCreate() = openFavoriteCategoryEdit(FavouritesCategoryEditActivity.NO_ID)
|
||||||
|
|
||||||
|
fun openMangaUpdates() {
|
||||||
|
startActivity(mangaUpdatesIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSettings() = startActivity(SettingsActivity::class.java)
|
||||||
|
|
||||||
|
fun openReaderSettings() {
|
||||||
|
startActivity(readerSettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openProxySettings() {
|
||||||
|
startActivity(proxySettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openDownloadsSetting() {
|
||||||
|
startActivity(downloadsSettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSourceSettings(source: MangaSource) {
|
||||||
|
startActivity(sourceSettingsIntent(contextOrNull() ?: return, source))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSuggestionsSettings() {
|
||||||
|
startActivity(suggestionsSettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSourcesSettings() {
|
||||||
|
startActivity(sourcesSettingsIntent(contextOrNull() ?: return))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java)
|
||||||
|
|
||||||
|
fun openScrobblerSettings(scrobbler: ScrobblerService) {
|
||||||
|
startActivity(
|
||||||
|
Intent(contextOrNull() ?: return, ScrobblerConfigActivity::class.java)
|
||||||
|
.putExtra(KEY_ID, scrobbler.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openSourceAuth(source: MangaSource) {
|
||||||
|
startActivity(sourceAuthIntent(contextOrNull() ?: return, source))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openManageSources() {
|
||||||
|
startActivity(
|
||||||
|
manageSourcesIntent(contextOrNull() ?: return),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openStatistic() = startActivity(StatsActivity::class.java)
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun openExternalBrowser(url: String, chooserTitle: CharSequence? = null): Boolean {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.data = url.toUriOrNull() ?: return false
|
||||||
|
return startActivitySafe(
|
||||||
|
if (!chooserTitle.isNullOrEmpty()) {
|
||||||
|
Intent.createChooser(intent, chooserTitle)
|
||||||
|
} else {
|
||||||
|
intent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun openSystemSyncSettings(account: Account): Boolean {
|
||||||
|
val args = Bundle(1)
|
||||||
|
args.putParcelable(ACCOUNT_KEY, account)
|
||||||
|
val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
|
||||||
|
intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args)
|
||||||
|
return startActivitySafe(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dialogs **/
|
||||||
|
|
||||||
|
fun showDownloadDialog(manga: Manga, snackbarHost: View?) = showDownloadDialog(setOf(manga), snackbarHost)
|
||||||
|
|
||||||
|
fun showDownloadDialog(manga: Collection<Manga>, snackbarHost: View?) {
|
||||||
|
if (manga.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val fm = getFragmentManager() ?: return
|
||||||
|
if (snackbarHost != null) {
|
||||||
|
getLifecycleOwner()?.let { lifecycleOwner ->
|
||||||
|
DownloadDialogFragment.registerCallback(fm, lifecycleOwner, snackbarHost)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DownloadDialogFragment.unregisterCallback(fm)
|
||||||
|
}
|
||||||
|
DownloadDialogFragment().withArgs(1) {
|
||||||
|
putParcelableArray(KEY_MANGA, manga.mapToArray { ParcelableManga(it, withDescription = false) })
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showLocalInfoDialog(manga: Manga) {
|
||||||
|
LocalInfoDialog().withArgs(1) {
|
||||||
|
putParcelable(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDirectorySelectDialog() {
|
||||||
|
MangaDirectorySelectDialog().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showFavoriteDialog(manga: Manga) = showFavoriteDialog(setOf(manga))
|
||||||
|
|
||||||
|
fun showFavoriteDialog(manga: Collection<Manga>) {
|
||||||
|
if (manga.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
FavoriteDialog().withArgs(1) {
|
||||||
|
putParcelableArrayList(
|
||||||
|
KEY_MANGA_LIST,
|
||||||
|
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) },
|
||||||
|
)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showTagDialog(tag: MangaTag) {
|
||||||
|
buildAlertDialog(contextOrNull() ?: return) {
|
||||||
|
setIcon(R.drawable.ic_tag)
|
||||||
|
setTitle(tag.title)
|
||||||
|
setItems(
|
||||||
|
arrayOf(
|
||||||
|
context.getString(R.string.search_on_s, tag.source.getTitle(context)),
|
||||||
|
context.getString(R.string.search_everywhere),
|
||||||
|
),
|
||||||
|
) { _, which ->
|
||||||
|
when (which) {
|
||||||
|
0 -> openList(tag)
|
||||||
|
1 -> openSearch(tag.title, SearchKind.TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNegativeButton(R.string.close, null)
|
||||||
|
setCancelable(true)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showAuthorDialog(author: String, source: MangaSource) {
|
||||||
|
buildAlertDialog(contextOrNull() ?: return) {
|
||||||
|
setIcon(R.drawable.ic_user)
|
||||||
|
setTitle(author)
|
||||||
|
setItems(
|
||||||
|
arrayOf(
|
||||||
|
context.getString(R.string.search_on_s, source.getTitle(context)),
|
||||||
|
context.getString(R.string.search_everywhere),
|
||||||
|
),
|
||||||
|
) { _, which ->
|
||||||
|
when (which) {
|
||||||
|
0 -> openList(source, MangaListFilter(author = author), null)
|
||||||
|
1 -> openSearch(author, SearchKind.AUTHOR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNegativeButton(R.string.close, null)
|
||||||
|
setCancelable(true)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showShareDialog(manga: Manga) {
|
||||||
|
if (manga.isBroken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (manga.isLocal) {
|
||||||
|
manga.url.toUri().toFileOrNull()?.let {
|
||||||
|
shareFile(it)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buildAlertDialog(contextOrNull() ?: return) {
|
||||||
|
setIcon(context.getThemeDrawable(materialR.attr.actionModeShareDrawable))
|
||||||
|
setTitle(R.string.share)
|
||||||
|
setItems(
|
||||||
|
arrayOf(
|
||||||
|
context.getString(R.string.link_to_manga_in_app),
|
||||||
|
context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)),
|
||||||
|
),
|
||||||
|
) { _, which ->
|
||||||
|
val link = when (which) {
|
||||||
|
0 -> manga.appUrl.toString()
|
||||||
|
1 -> manga.publicUrl
|
||||||
|
else -> return@setItems
|
||||||
|
}
|
||||||
|
shareLink(link, manga.title)
|
||||||
|
}
|
||||||
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
|
setCancelable(true)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showErrorDialog(error: Throwable, url: String? = null) {
|
||||||
|
ErrorDetailsDialog().withArgs(2) {
|
||||||
|
putSerializable(KEY_ERROR, error)
|
||||||
|
putString(KEY_URL, url)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showBackupRestoreDialog(fileUri: Uri) {
|
||||||
|
RestoreDialogFragment().withArgs(1) {
|
||||||
|
putString(KEY_FILE, fileUri.toString())
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showBackupCreateDialog() {
|
||||||
|
BackupDialogFragment().show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showImportDialog() {
|
||||||
|
ImportDialogFragment().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showFilterSheet(): Boolean = if (isFilterSupported()) {
|
||||||
|
FilterSheetFragment().showDistinct()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showTagsCatalogSheet(excludeMode: Boolean) {
|
||||||
|
if (!isFilterSupported()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TagsCatalogSheet().withArgs(1) {
|
||||||
|
putBoolean(KEY_EXCLUDE, excludeMode)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showListConfigSheet(section: ListConfigSection) {
|
||||||
|
ListConfigBottomSheet().withArgs(1) {
|
||||||
|
putParcelable(KEY_LIST_SECTION, section)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showStatisticSheet(manga: Manga) {
|
||||||
|
MangaStatsSheet().withArgs(1) {
|
||||||
|
putParcelable(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showReaderConfigSheet(mode: ReaderMode) {
|
||||||
|
ReaderConfigSheet().withArgs(1) {
|
||||||
|
putInt(KEY_READER_MODE, mode.id)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showWelcomeSheet() {
|
||||||
|
WelcomeSheet().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showChapterPagesSheet() {
|
||||||
|
ChaptersPagesSheet().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showChapterPagesSheet(defaultTab: Int) {
|
||||||
|
ChaptersPagesSheet().withArgs(1) {
|
||||||
|
putInt(KEY_TAB, defaultTab)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showScrobblingSelectorSheet(manga: Manga, scrobblerService: ScrobblerService?) {
|
||||||
|
ScrobblingSelectorSheet().withArgs(2) {
|
||||||
|
putParcelable(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
if (scrobblerService != null) {
|
||||||
|
putInt(KEY_ID, scrobblerService.id)
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showScrobblingInfoSheet(index: Int) {
|
||||||
|
ScrobblingInfoSheet().withArgs(1) {
|
||||||
|
putInt(KEY_INDEX, index)
|
||||||
|
}.showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showTrackerCategoriesConfigSheet() {
|
||||||
|
TrackerCategoriesConfigSheet().showDistinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) {
|
||||||
|
val context = contextOrNull() ?: return
|
||||||
|
when (settings.allowDownloadOnMeteredNetwork) {
|
||||||
|
TriStateOption.ENABLED -> onConfirmed(true)
|
||||||
|
TriStateOption.DISABLED -> onConfirmed(false)
|
||||||
|
TriStateOption.ASK -> {
|
||||||
|
if (!context.connectivityManager.isActiveNetworkMetered) {
|
||||||
|
onConfirmed(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val listener = DialogInterface.OnClickListener { _, which ->
|
||||||
|
when (which) {
|
||||||
|
DialogInterface.BUTTON_POSITIVE -> {
|
||||||
|
settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
|
||||||
|
onConfirmed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogInterface.BUTTON_NEUTRAL -> {
|
||||||
|
onConfirmed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogInterface.BUTTON_NEGATIVE -> {
|
||||||
|
settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
|
||||||
|
onConfirmed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BigButtonsAlertDialog.Builder(context)
|
||||||
|
.setIcon(R.drawable.ic_network_cellular)
|
||||||
|
.setTitle(R.string.download_cellular_confirm)
|
||||||
|
.setPositiveButton(R.string.allow_always, listener)
|
||||||
|
.setNeutralButton(R.string.allow_once, listener)
|
||||||
|
.setNegativeButton(R.string.dont_allow, listener)
|
||||||
|
.create()
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public utils **/
|
||||||
|
|
||||||
|
fun isFilterSupported(): Boolean = when {
|
||||||
|
fragment != null -> fragment.activity is FilterCoordinator.Owner
|
||||||
|
activity != null -> activity is FilterCoordinator.Owner
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isChapterPagesSheetShown(): Boolean {
|
||||||
|
val sheet = getFragmentManager()?.findFragmentByTag(fragmentTag<ChaptersPagesSheet>()) as? ChaptersPagesSheet
|
||||||
|
return sheet?.dialog?.isShowing == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeWelcomeSheet(): Boolean {
|
||||||
|
val tag = fragmentTag<WelcomeSheet>()
|
||||||
|
val sheet = fragment?.findFragmentByTagRecursive(tag)
|
||||||
|
?: activity?.supportFragmentManager?.findFragmentByTag(tag)
|
||||||
|
?: return false
|
||||||
|
return if (sheet is WelcomeSheet) {
|
||||||
|
sheet.dismissAllowingStateLoss()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private utils **/
|
||||||
|
|
||||||
|
private fun startActivity(intent: Intent, options: Bundle? = null) {
|
||||||
|
fragment?.startActivity(intent, options)
|
||||||
|
?: activity?.startActivity(intent, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startActivitySafe(intent: Intent): Boolean = try {
|
||||||
|
startActivity(intent)
|
||||||
|
true
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startActivity(activityClass: Class<out Activity>) {
|
||||||
|
startActivity(Intent(contextOrNull() ?: return, activityClass))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFragmentManager(): FragmentManager? {
|
||||||
|
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareLink(link: String, title: String) {
|
||||||
|
val context = contextOrNull() ?: return
|
||||||
|
ShareCompat.IntentBuilder(context)
|
||||||
|
.setText(link)
|
||||||
|
.setType(TYPE_TEXT)
|
||||||
|
.setChooserTitle(context.getString(R.string.share_s, title.ellipsize(12)))
|
||||||
|
.startChooser()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareFile(file: File) { // TODO directory sharing support
|
||||||
|
val context = contextOrNull() ?: return
|
||||||
|
val intentBuilder = ShareCompat.IntentBuilder(context)
|
||||||
|
.setType(TYPE_CBZ)
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
|
||||||
|
intentBuilder.addStream(uri)
|
||||||
|
intentBuilder.setChooserTitle(context.getString(R.string.share_s, file.name))
|
||||||
|
intentBuilder.startChooser()
|
||||||
|
}
|
||||||
|
|
||||||
|
@UiContext
|
||||||
|
private fun contextOrNull(): Context? = activity ?: fragment?.context
|
||||||
|
|
||||||
|
private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner
|
||||||
|
|
||||||
|
private fun DialogFragment.showDistinct(): Boolean {
|
||||||
|
val fm = this@AppRouter.getFragmentManager() ?: return false
|
||||||
|
val tag = javaClass.fragmentTag()
|
||||||
|
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
|
||||||
|
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
show(fm, tag)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DialogFragment.show() {
|
||||||
|
show(
|
||||||
|
this@AppRouter.getFragmentManager() ?: return,
|
||||||
|
javaClass.fragmentTag(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Fragment.findFragmentByTagRecursive(fragmentTag: String): Fragment? {
|
||||||
|
childFragmentManager.findFragmentByTag(fragmentTag)?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val parent = parentFragment
|
||||||
|
return if (parent != null) {
|
||||||
|
parent.findFragmentByTagRecursive(fragmentTag)
|
||||||
|
} else {
|
||||||
|
parentFragmentManager.findFragmentByTag(fragmentTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun from(view: View): AppRouter? = runCatching {
|
||||||
|
AppRouter(view.findFragment<Fragment>())
|
||||||
|
}.getOrElse {
|
||||||
|
(view.context.findActivity() as? FragmentActivity)?.let(::AppRouter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java)
|
||||||
|
.putExtra(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
|
||||||
|
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
|
||||||
|
.putExtra(KEY_ID, mangaId)
|
||||||
|
|
||||||
|
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent =
|
||||||
|
Intent(context, MangaListActivity::class.java)
|
||||||
|
.setAction(ACTION_MANGA_EXPLORE)
|
||||||
|
.putExtra(KEY_SOURCE, source.name)
|
||||||
|
.apply {
|
||||||
|
if (!filter.isNullOrEmpty()) {
|
||||||
|
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
|
||||||
|
}
|
||||||
|
if (sortOrder != null) {
|
||||||
|
putExtra(KEY_SORT_ORDER, sortOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
|
||||||
|
Intent(context, CloudFlareActivity::class.java).apply {
|
||||||
|
data = exception.url.toUri()
|
||||||
|
putExtra(KEY_SOURCE, exception.source?.name)
|
||||||
|
exception.headers[CommonHeaders.USER_AGENT]?.let {
|
||||||
|
putExtra(KEY_USER_AGENT, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
|
||||||
|
|
||||||
|
fun homeIntent(context: Context) = Intent(context, MainActivity::class.java)
|
||||||
|
|
||||||
|
fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java)
|
||||||
|
|
||||||
|
fun readerSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_READER)
|
||||||
|
|
||||||
|
fun suggestionsSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_SUGGESTIONS)
|
||||||
|
|
||||||
|
fun trackerSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_TRACKER)
|
||||||
|
|
||||||
|
fun proxySettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_PROXY)
|
||||||
|
|
||||||
|
fun historySettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_HISTORY)
|
||||||
|
|
||||||
|
fun sourcesSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_SOURCES)
|
||||||
|
|
||||||
|
fun manageSourcesIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_MANAGE_SOURCES)
|
||||||
|
|
||||||
|
fun downloadsSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_MANAGE_DOWNLOADS)
|
||||||
|
|
||||||
|
fun sourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) {
|
||||||
|
is MangaSourceInfo -> sourceSettingsIntent(context, source.mangaSource)
|
||||||
|
is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
.setData(Uri.fromParts("package", source.packageName, null))
|
||||||
|
|
||||||
|
else -> Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_SOURCE)
|
||||||
|
.putExtra(KEY_SOURCE, source.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sourceAuthIntent(context: Context, source: MangaSource): Intent {
|
||||||
|
return Intent(context, SourceAuthActivity::class.java)
|
||||||
|
.putExtra(KEY_SOURCE, source.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isShareSupported(manga: Manga): Boolean = when {
|
||||||
|
manga.isBroken -> false
|
||||||
|
manga.isLocal -> manga.url.toUri().toFileOrNull() != null
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
const val KEY_DATA = "data"
|
||||||
|
const val KEY_ENTRIES = "entries"
|
||||||
|
const val KEY_ERROR = "error"
|
||||||
|
const val KEY_EXCLUDE = "exclude"
|
||||||
|
const val KEY_FILE = "file"
|
||||||
|
const val KEY_FILTER = "filter"
|
||||||
|
const val KEY_ID = "id"
|
||||||
|
const val KEY_INDEX = "index"
|
||||||
|
const val KEY_KIND = "kind"
|
||||||
|
const val KEY_LIST_SECTION = "list_section"
|
||||||
|
const val KEY_MANGA = "manga"
|
||||||
|
const val KEY_MANGA_LIST = "manga_list"
|
||||||
|
const val KEY_PAGES = "pages"
|
||||||
|
const val KEY_QUERY = "query"
|
||||||
|
const val KEY_READER_MODE = "reader_mode"
|
||||||
|
const val KEY_SORT_ORDER = "sort_order"
|
||||||
|
const val KEY_SOURCE = "source"
|
||||||
|
const val KEY_TAB = "tab"
|
||||||
|
const val KEY_TITLE = "title"
|
||||||
|
const val KEY_URL = "url"
|
||||||
|
const val KEY_USER_AGENT = "user_agent"
|
||||||
|
|
||||||
|
const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
|
||||||
|
const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS"
|
||||||
|
const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
|
||||||
|
const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA"
|
||||||
|
const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY"
|
||||||
|
const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS"
|
||||||
|
const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS"
|
||||||
|
const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES"
|
||||||
|
const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
|
||||||
|
const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
|
||||||
|
|
||||||
|
private const val ACCOUNT_KEY = "account"
|
||||||
|
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
||||||
|
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
|
||||||
|
|
||||||
|
private const val TYPE_TEXT = "text/plain"
|
||||||
|
private const val TYPE_IMAGE = "image/*"
|
||||||
|
private const val TYPE_CBZ = "application/x-cbz"
|
||||||
|
|
||||||
|
private fun Class<out Fragment>.fragmentTag() = name // TODO
|
||||||
|
|
||||||
|
private inline fun <reified F : Fragment> fragmentTag() = F::class.java.fragmentTag()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.nav
|
||||||
|
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface AppRouterEntryPoint {
|
||||||
|
|
||||||
|
val settings: AppSettings
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.nav
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_ID
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_MANGA
|
||||||
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
|
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
|
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -25,7 +26,7 @@ class MangaIntent private constructor(
|
|||||||
constructor(savedStateHandle: SavedStateHandle) : this(
|
constructor(savedStateHandle: SavedStateHandle) : this(
|
||||||
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
|
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
|
||||||
id = savedStateHandle[KEY_ID] ?: ID_NONE,
|
id = savedStateHandle[KEY_ID] ?: ID_NONE,
|
||||||
uri = savedStateHandle[BaseActivity.EXTRA_DATA],
|
uri = savedStateHandle[AppRouter.KEY_DATA],
|
||||||
)
|
)
|
||||||
|
|
||||||
constructor(args: Bundle?) : this(
|
constructor(args: Bundle?) : this(
|
||||||
@@ -41,9 +42,6 @@ class MangaIntent private constructor(
|
|||||||
|
|
||||||
const val ID_NONE = 0L
|
const val ID_NONE = 0L
|
||||||
|
|
||||||
const val KEY_MANGA = "manga"
|
|
||||||
const val KEY_ID = "id"
|
|
||||||
|
|
||||||
fun of(manga: Manga) = MangaIntent(manga, manga.id, null)
|
fun of(manga: Manga) = MangaIntent(manga, manga.id, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
39
app/src/main/kotlin/org/koitharu/kotatsu/core/nav/NavUtil.kt
Normal file
39
app/src/main/kotlin/org/koitharu/kotatsu/core/nav/NavUtil.kt
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package org.koitharu.kotatsu.core.nav
|
||||||
|
|
||||||
|
import android.app.ActivityOptions
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||||
|
|
||||||
|
inline val FragmentActivity.router: AppRouter
|
||||||
|
get() = AppRouter(this)
|
||||||
|
|
||||||
|
inline val Fragment.router: AppRouter
|
||||||
|
get() = AppRouter(this)
|
||||||
|
|
||||||
|
tailrec fun Fragment.dismissParentDialog(): Boolean {
|
||||||
|
return when (val parent = parentFragment) {
|
||||||
|
null -> return false
|
||||||
|
is DialogFragment -> {
|
||||||
|
parent.dismiss()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> parent.dismissParentDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
|
||||||
|
ActivityOptions.makeScaleUpAnimation(
|
||||||
|
view,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
view.width,
|
||||||
|
view.height,
|
||||||
|
).toBundle()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package org.koitharu.kotatsu.core.nav
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class ReaderIntent private constructor(
|
||||||
|
val intent: Intent,
|
||||||
|
) {
|
||||||
|
|
||||||
|
class Builder(context: Context) {
|
||||||
|
|
||||||
|
private val intent = Intent(context, ReaderActivity::class.java)
|
||||||
|
.setAction(ACTION_MANGA_READ)
|
||||||
|
|
||||||
|
fun manga(manga: Manga) = apply {
|
||||||
|
intent.putExtra(AppRouter.KEY_MANGA, ParcelableManga(manga))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mangaId(mangaId: Long) = apply {
|
||||||
|
intent.putExtra(AppRouter.KEY_ID, mangaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun incognito(incognito: Boolean) = apply {
|
||||||
|
intent.putExtra(EXTRA_INCOGNITO, incognito)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun branch(branch: String?) = apply {
|
||||||
|
intent.putExtra(EXTRA_BRANCH, branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun state(state: ReaderState?) = apply {
|
||||||
|
intent.putExtra(EXTRA_STATE, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bookmark(bookmark: Bookmark) = manga(
|
||||||
|
bookmark.manga,
|
||||||
|
).state(
|
||||||
|
ReaderState(
|
||||||
|
chapterId = bookmark.chapterId,
|
||||||
|
page = bookmark.page,
|
||||||
|
scroll = bookmark.scroll,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun build() = ReaderIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA"
|
||||||
|
const val EXTRA_STATE = "state"
|
||||||
|
const val EXTRA_BRANCH = "branch"
|
||||||
|
const val EXTRA_INCOGNITO = "incognito"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import okio.IOException
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.net.Proxy
|
|
||||||
import java.net.ProxySelector
|
|
||||||
import java.net.SocketAddress
|
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
class AppProxySelector(
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) : ProxySelector() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
setDefault(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cachedProxy: Proxy? = null
|
|
||||||
|
|
||||||
override fun select(uri: URI?): List<Proxy> {
|
|
||||||
return listOf(getProxy())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
|
|
||||||
ioe?.printStackTraceDebug()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getProxy(): Proxy {
|
|
||||||
val type = settings.proxyType
|
|
||||||
val address = settings.proxyAddress
|
|
||||||
val port = settings.proxyPort
|
|
||||||
if (type == Proxy.Type.DIRECT) {
|
|
||||||
return Proxy.NO_PROXY
|
|
||||||
}
|
|
||||||
if (address.isNullOrEmpty() || port == 0) {
|
|
||||||
throw ProxyConfigException()
|
|
||||||
}
|
|
||||||
cachedProxy?.let {
|
|
||||||
val addr = it.address() as? InetSocketAddress
|
|
||||||
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val proxy = Proxy(type, InetSocketAddress(address, port))
|
|
||||||
cachedProxy = proxy
|
|
||||||
return proxy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,7 @@ object CommonHeaders {
|
|||||||
const val CACHE_CONTROL = "Cache-Control"
|
const val CACHE_CONTROL = "Cache-Control"
|
||||||
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
|
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
|
||||||
const val RETRY_AFTER = "Retry-After"
|
const val RETRY_AFTER = "Retry-After"
|
||||||
|
const val MANGA_SOURCE = "X-Manga-Source"
|
||||||
|
|
||||||
val CACHE_CONTROL_NO_STORE: CacheControl
|
val CACHE_CONTROL_NO_STORE: CacheControl
|
||||||
get() = CacheControl.Builder().noStore().build()
|
get() = CacheControl.Builder().noStore().build()
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import okhttp3.Request
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
@@ -30,15 +31,17 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
override fun intercept(chain: Chain): Response {
|
override fun intercept(chain: Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
val source = request.tag(MangaSource::class.java)
|
val source = request.tag(MangaSource::class.java)
|
||||||
val repository = if (source == null || source == UnknownMangaSource) {
|
?: request.headers[CommonHeaders.MANGA_SOURCE]?.let { MangaSource(it) }
|
||||||
|
val repository = if (source is MangaParserSource) {
|
||||||
|
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||||
|
} else {
|
||||||
if (BuildConfig.DEBUG && source == null) {
|
if (BuildConfig.DEBUG && source == null) {
|
||||||
Log.w("Http", "Request without source tag: ${request.url}")
|
Log.w("Http", "Request without source tag: ${request.url}")
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
} else {
|
|
||||||
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
|
||||||
}
|
}
|
||||||
val headersBuilder = request.headers.newBuilder()
|
val headersBuilder = request.headers.newBuilder()
|
||||||
|
.removeAll(CommonHeaders.MANGA_SOURCE)
|
||||||
repository?.getRequestHeaders()?.let {
|
repository?.getRequestHeaders()?.let {
|
||||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||||
}
|
}
|
||||||
@@ -62,7 +65,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
|
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
|
||||||
intercept(chain)
|
intercept(chain)
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
if (e is IOException) {
|
if (e is IOException || e is Error) {
|
||||||
throw e
|
throw e
|
||||||
} else {
|
} else {
|
||||||
// only IOException can be safely thrown from an Interceptor
|
// only IOException can be safely thrown from an Interceptor
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class DoHManager(
|
|||||||
).build()
|
).build()
|
||||||
|
|
||||||
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||||
.url("https://2ca4h4crra.cloudflare-gateway.com/dns-query".toHttpUrl())
|
.url("https://0ms.dev/dns-query".toHttpUrl())
|
||||||
.resolvePublicAddresses(true)
|
.resolvePublicAddresses(true)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.WrapperIOException
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
|
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
|
||||||
|
|
||||||
class GZipInterceptor : Interceptor {
|
class GZipInterceptor : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response = try {
|
||||||
val newRequest = chain.request().newBuilder()
|
val request = chain.request()
|
||||||
newRequest.addHeader(CONTENT_ENCODING, "gzip")
|
if (request.body is MultipartBody) {
|
||||||
return try {
|
chain.proceed(request)
|
||||||
|
} else {
|
||||||
|
val newRequest = request.newBuilder()
|
||||||
|
newRequest.addHeader(CONTENT_ENCODING, "gzip")
|
||||||
chain.proceed(newRequest.build())
|
chain.proceed(newRequest.build())
|
||||||
} catch (e: NullPointerException) {
|
|
||||||
throw IOException(e)
|
|
||||||
}
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw WrapperIOException(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
|||||||
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
||||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
||||||
|
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
@@ -62,14 +63,15 @@ interface NetworkModule {
|
|||||||
cache: Cache,
|
cache: Cache,
|
||||||
cookieJar: CookieJar,
|
cookieJar: CookieJar,
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
|
proxyProvider: ProxyProvider,
|
||||||
): OkHttpClient = OkHttpClient.Builder().apply {
|
): OkHttpClient = OkHttpClient.Builder().apply {
|
||||||
assertNotInMainThread()
|
assertNotInMainThread()
|
||||||
connectTimeout(20, TimeUnit.SECONDS)
|
connectTimeout(20, TimeUnit.SECONDS)
|
||||||
readTimeout(60, TimeUnit.SECONDS)
|
readTimeout(60, TimeUnit.SECONDS)
|
||||||
writeTimeout(20, TimeUnit.SECONDS)
|
writeTimeout(20, TimeUnit.SECONDS)
|
||||||
cookieJar(cookieJar)
|
cookieJar(cookieJar)
|
||||||
proxySelector(AppProxySelector(settings))
|
proxySelector(proxyProvider.selector)
|
||||||
proxyAuthenticator(ProxyAuthenticator(settings))
|
proxyAuthenticator(proxyProvider.authenticator)
|
||||||
dns(DoHManager(cache, settings))
|
dns(DoHManager(cache, settings))
|
||||||
if (settings.isSSLBypassEnabled) {
|
if (settings.isSSLBypassEnabled) {
|
||||||
disableCertificateVerification()
|
disableCertificateVerification()
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import okhttp3.Authenticator
|
|
||||||
import okhttp3.Credentials
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.Route
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import java.net.PasswordAuthentication
|
|
||||||
import java.net.Proxy
|
|
||||||
|
|
||||||
class ProxyAuthenticator(
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) : Authenticator, java.net.Authenticator() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
setDefault(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun authenticate(route: Route?, response: Response): Request? {
|
|
||||||
if (!isProxyEnabled()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val login = settings.proxyLogin ?: return null
|
|
||||||
val password = settings.proxyPassword ?: return null
|
|
||||||
val credential = Credentials.basic(login, password)
|
|
||||||
return response.request.newBuilder()
|
|
||||||
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
|
||||||
if (!isProxyEnabled()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val login = settings.proxyLogin ?: return null
|
|
||||||
val password = settings.proxyPassword ?: return null
|
|
||||||
return PasswordAuthentication(login, password.toCharArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import androidx.annotation.WorkerThread
|
|||||||
import androidx.core.util.Predicate
|
import androidx.core.util.Predicate
|
||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import org.koitharu.kotatsu.core.util.ext.newBuilder
|
import org.koitharu.kotatsu.parsers.util.newBuilder
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.network.imageproxy
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.collection.ArraySet
|
import androidx.collection.ArraySet
|
||||||
import coil.intercept.Interceptor
|
import coil3.intercept.Interceptor
|
||||||
import coil.network.HttpException
|
import coil3.network.HttpException
|
||||||
import coil.request.ErrorResult
|
import coil3.request.ErrorResult
|
||||||
import coil.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil.request.ImageResult
|
import coil3.request.ImageResult
|
||||||
import coil.request.SuccessResult
|
import coil3.request.SuccessResult
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -17,8 +17,8 @@ import org.jsoup.HttpStatusException
|
|||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.isHttpOrHttps
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
@@ -35,14 +35,14 @@ abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||||
return chain.proceed(request)
|
return chain.proceed()
|
||||||
}
|
}
|
||||||
val newRequest = onInterceptImageRequest(request, url)
|
val newRequest = onInterceptImageRequest(request, url)
|
||||||
return when (val result = chain.proceed(newRequest)) {
|
return when (val result = chain.withRequest(newRequest).proceed()) {
|
||||||
is SuccessResult -> result
|
is SuccessResult -> result
|
||||||
is ErrorResult -> {
|
is ErrorResult -> {
|
||||||
logDebug(result.throwable, newRequest.data)
|
logDebug(result.throwable, newRequest.data)
|
||||||
chain.proceed(request).also {
|
chain.proceed().also {
|
||||||
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
|
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
|
||||||
blacklist.add(url.host)
|
blacklist.add(url.host)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.network.imageproxy
|
package org.koitharu.kotatsu.core.network.imageproxy
|
||||||
|
|
||||||
import coil.intercept.Interceptor
|
import coil3.intercept.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.network.imageproxy
|
package org.koitharu.kotatsu.core.network.imageproxy
|
||||||
|
|
||||||
import coil.intercept.Interceptor
|
import coil3.intercept.Interceptor
|
||||||
import coil.request.ImageResult
|
import coil3.request.ImageResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -26,7 +26,7 @@ class RealImageProxyInterceptor @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||||
return delegate.value?.intercept(chain) ?: chain.proceed(chain.request)
|
return delegate.value?.intercept(chain) ?: chain.proceed()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.network.imageproxy
|
package org.koitharu.kotatsu.core.network.imageproxy
|
||||||
|
|
||||||
import coil.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil.size.Dimension
|
import coil3.size.Dimension
|
||||||
import coil.size.isOriginal
|
import coil3.size.isOriginal
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.network.imageproxy
|
package org.koitharu.kotatsu.core.network.imageproxy
|
||||||
|
|
||||||
import coil.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network.proxy
|
||||||
|
|
||||||
|
import androidx.webkit.ProxyConfig
|
||||||
|
import androidx.webkit.ProxyController
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import okhttp3.Authenticator
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.Route
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.PasswordAuthentication
|
||||||
|
import java.net.Proxy
|
||||||
|
import java.net.ProxySelector
|
||||||
|
import java.net.SocketAddress
|
||||||
|
import java.net.URI
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import java.net.Authenticator as JavaAuthenticator
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ProxyProvider @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var cachedProxy: Proxy? = null
|
||||||
|
|
||||||
|
val selector = object : ProxySelector() {
|
||||||
|
override fun select(uri: URI?): List<Proxy> {
|
||||||
|
return listOf(getProxy())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: okio.IOException?) {
|
||||||
|
ioe?.printStackTraceDebug()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val authenticator = ProxyAuthenticator()
|
||||||
|
|
||||||
|
init {
|
||||||
|
ProxySelector.setDefault(selector)
|
||||||
|
JavaAuthenticator.setDefault(authenticator)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun applyWebViewConfig() {
|
||||||
|
val isProxyEnabled = isProxyEnabled()
|
||||||
|
if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
|
||||||
|
if (isProxyEnabled) {
|
||||||
|
throw IllegalArgumentException("Proxy for WebView is not supported") // TODO localize
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val controller = ProxyController.getInstance()
|
||||||
|
if (settings.proxyType == Proxy.Type.DIRECT) {
|
||||||
|
suspendCoroutine { cont ->
|
||||||
|
controller.clearProxyOverride(
|
||||||
|
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
|
||||||
|
) {
|
||||||
|
cont.resume(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val url = buildString {
|
||||||
|
when (settings.proxyType) {
|
||||||
|
Proxy.Type.DIRECT -> Unit
|
||||||
|
Proxy.Type.HTTP -> append("http")
|
||||||
|
Proxy.Type.SOCKS -> append("socks")
|
||||||
|
}
|
||||||
|
append("://")
|
||||||
|
append(settings.proxyAddress)
|
||||||
|
append(':')
|
||||||
|
append(settings.proxyPort)
|
||||||
|
}
|
||||||
|
if (settings.proxyType == Proxy.Type.SOCKS) {
|
||||||
|
System.setProperty("java.net.socks.username", settings.proxyLogin);
|
||||||
|
System.setProperty("java.net.socks.password", settings.proxyPassword);
|
||||||
|
}
|
||||||
|
val proxyConfig = ProxyConfig.Builder()
|
||||||
|
.addProxyRule(url)
|
||||||
|
.build()
|
||||||
|
suspendCoroutine { cont ->
|
||||||
|
controller.setProxyOverride(
|
||||||
|
proxyConfig,
|
||||||
|
(cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(),
|
||||||
|
) {
|
||||||
|
cont.resume(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT
|
||||||
|
|
||||||
|
private fun getProxy(): Proxy {
|
||||||
|
val type = settings.proxyType
|
||||||
|
val address = settings.proxyAddress
|
||||||
|
val port = settings.proxyPort
|
||||||
|
if (type == Proxy.Type.DIRECT) {
|
||||||
|
return Proxy.NO_PROXY
|
||||||
|
}
|
||||||
|
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
|
||||||
|
throw ProxyConfigException()
|
||||||
|
}
|
||||||
|
cachedProxy?.let {
|
||||||
|
val addr = it.address() as? InetSocketAddress
|
||||||
|
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val proxy = Proxy(type, InetSocketAddress(address, port))
|
||||||
|
cachedProxy = proxy
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class ProxyAuthenticator : Authenticator, JavaAuthenticator() {
|
||||||
|
|
||||||
|
override fun authenticate(route: Route?, response: Response): Request? {
|
||||||
|
if (!isProxyEnabled()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val login = settings.proxyLogin ?: return null
|
||||||
|
val password = settings.proxyPassword ?: return null
|
||||||
|
val credential = Credentials.basic(login, password)
|
||||||
|
return response.request.newBuilder()
|
||||||
|
.header(CommonHeaders.PROXY_AUTHORIZATION, credential)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||||
|
if (!isProxyEnabled()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val login = settings.proxyLogin ?: return null
|
||||||
|
val password = settings.proxyPassword ?: return null
|
||||||
|
return PasswordAuthentication(login, password.toCharArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,11 @@ import androidx.core.content.pm.ShortcutManagerCompat
|
|||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import androidx.room.InvalidationTracker
|
import androidx.room.InvalidationTracker
|
||||||
import coil.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil.size.Scale
|
import coil3.request.transformations
|
||||||
import coil.size.Size
|
import coil3.size.Scale
|
||||||
|
import coil3.size.Size
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -22,21 +23,21 @@ import kotlinx.coroutines.withContext
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.nav.ReaderIntent
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
|
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
|
||||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -132,13 +133,13 @@ class AppShortcutManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat {
|
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat = withContext(Dispatchers.Default) {
|
||||||
val icon = runCatchingCancellable {
|
val icon = runCatchingCancellable {
|
||||||
coil.execute(
|
coil.execute(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(manga.coverUrl)
|
.data(manga.coverUrl)
|
||||||
.size(iconSize)
|
.size(iconSize)
|
||||||
.source(manga.source)
|
.mangaSourceExtra(manga.source)
|
||||||
.scale(Scale.FILL)
|
.scale(Scale.FILL)
|
||||||
.transformations(ThumbnailTransformation())
|
.transformations(ThumbnailTransformation())
|
||||||
.build(),
|
.build(),
|
||||||
@@ -148,17 +149,17 @@ class AppShortcutManager @Inject constructor(
|
|||||||
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
||||||
)
|
)
|
||||||
mangaRepository.storeManga(manga)
|
mangaRepository.storeManga(manga)
|
||||||
return ShortcutInfoCompat.Builder(context, manga.id.toString())
|
ShortcutInfoCompat.Builder(context, manga.id.toString())
|
||||||
.setShortLabel(manga.title)
|
.setShortLabel(manga.title)
|
||||||
.setLongLabel(manga.title)
|
.setLongLabel(manga.title)
|
||||||
.setIcon(icon)
|
.setIcon(icon)
|
||||||
.setLongLived(true)
|
.setLongLived(true)
|
||||||
.setIntent(
|
.setIntent(
|
||||||
ReaderActivity.IntentBuilder(context)
|
ReaderIntent.Builder(context)
|
||||||
.mangaId(manga.id)
|
.mangaId(manga.id)
|
||||||
.build(),
|
.build()
|
||||||
)
|
.intent,
|
||||||
.build()
|
).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat = withContext(Dispatchers.Default) {
|
private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat = withContext(Dispatchers.Default) {
|
||||||
@@ -180,7 +181,7 @@ class AppShortcutManager @Inject constructor(
|
|||||||
.setLongLabel(title)
|
.setLongLabel(title)
|
||||||
.setIcon(icon)
|
.setIcon(icon)
|
||||||
.setLongLived(true)
|
.setLongLived(true)
|
||||||
.setIntent(MangaListActivity.newIntent(context, source, null))
|
.setIntent(AppRouter.listIntent(context, source, null, null))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user