Compare commits
336 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dddb00d5ef | ||
|
|
c9d878a0b7 | ||
|
|
dcb92ed1af | ||
|
|
749bc4a837 | ||
|
|
94807b7788 | ||
|
|
0fe7c66850 | ||
|
|
20cd8413dc | ||
|
|
30df4ede6c | ||
|
|
4aa6baf569 | ||
|
|
d8a4303c50 | ||
|
|
b355e2ee88 | ||
|
|
55e3b5fb9b | ||
|
|
a59853e37a | ||
|
|
ccc665d218 | ||
|
|
02650f5c2a | ||
|
|
24172a1137 | ||
|
|
034d69d490 | ||
|
|
12fc0542d3 | ||
|
|
dcf80ed396 | ||
|
|
28badb7f6c | ||
|
|
19cc158ef8 | ||
|
|
a2eeae3319 | ||
|
|
c9336a753d | ||
|
|
ea23468ecd | ||
|
|
143643fcd8 | ||
|
|
25eb05d305 | ||
|
|
bf217b3cbf | ||
|
|
9e2b60e15e | ||
|
|
4dba90361c | ||
|
|
8dea483f64 | ||
|
|
dc2e603356 | ||
|
|
14973298a0 | ||
|
|
7efc47724e | ||
|
|
c51218240e | ||
|
|
2762caaa8f | ||
|
|
70d66e5a90 | ||
|
|
fc1d704f6f | ||
|
|
c2c3b0f757 | ||
|
|
8d519dd80f | ||
|
|
3b5a9cd2b4 | ||
|
|
95f4d39893 | ||
|
|
3173e30caf | ||
|
|
0dccc66f54 | ||
|
|
6b3dd23c01 | ||
|
|
1c6a125174 | ||
|
|
f3f269c7fa | ||
|
|
15f37644c0 | ||
|
|
c2079ebca5 | ||
|
|
1146269992 | ||
|
|
099362d198 | ||
|
|
22d203fc60 | ||
|
|
19602144ef | ||
|
|
44bbcd7fe3 | ||
|
|
efe5e07c2c | ||
|
|
4e633ff735 | ||
|
|
fef8333763 | ||
|
|
a741f8451a | ||
|
|
55baf5a3f3 | ||
|
|
6390774d86 | ||
|
|
51a5128e70 | ||
|
|
53d81507e4 | ||
|
|
dcf7236ba2 | ||
|
|
a54744abc6 | ||
|
|
22e2411c77 | ||
|
|
3f66c142b8 | ||
|
|
40f262b0ef | ||
|
|
0f68be9663 | ||
|
|
0b8afe9c40 | ||
|
|
734846a018 | ||
|
|
754ccc4197 | ||
|
|
ef691b1aed | ||
|
|
e75035b33a | ||
|
|
f675c606a2 | ||
|
|
a5199e2f06 | ||
|
|
1b80e48ed4 | ||
|
|
07e81f21c7 | ||
|
|
0dbd01f6fc | ||
|
|
4b453b58dd | ||
|
|
1575bb5242 | ||
|
|
55137cf899 | ||
|
|
f190ff810e | ||
|
|
47c13b46f7 | ||
|
|
2ad9f38906 | ||
|
|
2783c62ace | ||
|
|
c1a65f8055 | ||
|
|
6e5d8e99ca | ||
|
|
020c3b8bba | ||
|
|
76162a06e3 | ||
|
|
19f398d309 | ||
|
|
1bd916371a | ||
|
|
25ae23963e | ||
|
|
146ba95af6 | ||
|
|
cd40dab8a4 | ||
|
|
ee10b013a1 | ||
|
|
8c79df3d35 | ||
|
|
2c2db1ca96 | ||
|
|
f556c0b127 | ||
|
|
d2ed8a1ace | ||
|
|
024e3c11ee | ||
|
|
23ba302df8 | ||
|
|
34e54e43e0 | ||
|
|
07a8de6225 | ||
|
|
a3df6f799c | ||
|
|
d5722790ef | ||
|
|
8bf540abbe | ||
|
|
5241fa0d13 | ||
|
|
87e0c931a2 | ||
|
|
a51412801a | ||
|
|
a6c188d647 | ||
|
|
831632cb8f | ||
|
|
ad59bf50f4 | ||
|
|
6fe6c05327 | ||
|
|
66645d93f8 | ||
|
|
f2582bce1d | ||
|
|
b5053b7820 | ||
|
|
e4df81495d | ||
|
|
295c5bed9f | ||
|
|
5fd1cbadcd | ||
|
|
9dd86f57e6 | ||
|
|
bce6d71743 | ||
|
|
6367c06f49 | ||
|
|
3aa8e9d6d3 | ||
|
|
ac2b367312 | ||
|
|
5cd9b02159 | ||
|
|
0bd62c6925 | ||
|
|
d657216a69 | ||
|
|
39f91464dc | ||
|
|
05422b95a1 | ||
|
|
554e3c1b61 | ||
|
|
56ece80f2a | ||
|
|
3ebde0284d | ||
|
|
c993488fe7 | ||
|
|
e65a3b43f6 | ||
|
|
f11a9d8235 | ||
|
|
8a4bd9a19a | ||
|
|
cffc6cfd39 | ||
|
|
1568a48328 | ||
|
|
0b47b113e0 | ||
|
|
67a5ef016c | ||
|
|
09c049ea9d | ||
|
|
0dc1cad52b | ||
|
|
782ea0541e | ||
|
|
b220703dd4 | ||
|
|
c5b6586cf4 | ||
|
|
1ba40ea248 | ||
|
|
e8fd2b0dcf | ||
|
|
046b7b6ef1 | ||
|
|
907856a0df | ||
|
|
071509ecd1 | ||
|
|
a0cb34b984 | ||
|
|
7fe8217f6d | ||
|
|
58937f9fc6 | ||
|
|
528b85e9ce | ||
|
|
b57fdd5a99 | ||
|
|
1ad29cebd7 | ||
|
|
7516303b7d | ||
|
|
b2bfebaea2 | ||
|
|
9fcff1eac7 | ||
|
|
19446db192 | ||
|
|
609f2bd134 | ||
|
|
3ef7c6adb0 | ||
|
|
62e7e5d8c3 | ||
|
|
644f0af262 | ||
|
|
a1e5d78877 | ||
|
|
635839065d | ||
|
|
bb6f7b1e9f | ||
|
|
30e43d3bfe | ||
|
|
1f0180d601 | ||
|
|
cdce2af4a3 | ||
|
|
11212ed071 | ||
|
|
e2902fa1ba | ||
|
|
5158f2a70a | ||
|
|
f9e4752b8c | ||
|
|
901ffebf97 | ||
|
|
dba727bfcb | ||
|
|
3ee97a3b99 | ||
|
|
57d1f54318 | ||
|
|
02073f6d45 | ||
|
|
b66a77843e | ||
|
|
03518dd9b4 | ||
|
|
d926f334e8 | ||
|
|
e2f8d8e022 | ||
|
|
38b342b721 | ||
|
|
b036a8ed94 | ||
|
|
e4fda86bf1 | ||
|
|
6e20cee972 | ||
|
|
8901d02dba | ||
|
|
a87b37ce1c | ||
|
|
4f22e29ad6 | ||
|
|
6effb928fd | ||
|
|
1b1d0014da | ||
|
|
a9632f542b | ||
|
|
a2c256d47f | ||
|
|
f87a75e61e | ||
|
|
09354ae31f | ||
|
|
fb25b8fb3a | ||
|
|
c8b935ccc3 | ||
|
|
7f0376d792 | ||
|
|
0c56e730fe | ||
|
|
a7138d23ac | ||
|
|
a0de73a7ed | ||
|
|
90f0846fb4 | ||
|
|
9425d29596 | ||
|
|
9bb76cc0b2 | ||
|
|
ad0452486f | ||
|
|
855b55da9d | ||
|
|
436168b940 | ||
|
|
681c80dc3e | ||
|
|
4855b2c160 | ||
|
|
89d395178c | ||
|
|
9942ad5e56 | ||
|
|
d59b0626bc | ||
|
|
63054e55d6 | ||
|
|
486daf69bf | ||
|
|
af209d7048 | ||
|
|
c15a0ece3e | ||
|
|
6bf034fd37 | ||
|
|
5bccc595a8 | ||
|
|
9559e148c6 | ||
|
|
637a040a0b | ||
|
|
2bdf146548 | ||
|
|
22831a9796 | ||
|
|
b5bc64c89f | ||
|
|
f2ad58bc97 | ||
|
|
835a1c73b6 | ||
|
|
5b8a628715 | ||
|
|
4f5418e074 | ||
|
|
1cf56b2303 | ||
|
|
a47dcd9ec2 | ||
|
|
7873cc4099 | ||
|
|
9002915e30 | ||
|
|
099d9df84c | ||
|
|
e531e6bcb8 | ||
|
|
77ed44bb08 | ||
|
|
1b0b495029 | ||
|
|
b6296fd586 | ||
|
|
985b062218 | ||
|
|
b6f57e5656 | ||
|
|
3d285104a4 | ||
|
|
100073f45e | ||
|
|
c1d577bdf3 | ||
|
|
2214c20742 | ||
|
|
688a9fe4d5 | ||
|
|
af5df32fbe | ||
|
|
d739e30c84 | ||
|
|
32eb273fa9 | ||
|
|
8c5231bb3d | ||
|
|
be4fb3e873 | ||
|
|
d28eff7a75 | ||
|
|
b81063910b | ||
|
|
702ee70f70 | ||
|
|
c5bd979645 | ||
|
|
e515069b53 | ||
|
|
3255fba3c4 | ||
|
|
144e66bedb | ||
|
|
05d22167c4 | ||
|
|
e5c765dd2f | ||
|
|
557b69d73f | ||
|
|
1e22e8de45 | ||
|
|
9ea1122ca0 | ||
|
|
4faef85086 | ||
|
|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,3 +26,4 @@
|
||||
.cxx
|
||||
/.idea/deviceManager.xml
|
||||
/.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>
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
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://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
[](https://github.com/KotatsuApp/kotatsu-parsers)   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Download
|
||||
|
||||
- **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).
|
||||
|
||||
### Main Features
|
||||
|
||||
|
||||
166
app/build.gradle
166
app/build.gradle
@@ -1,3 +1,5 @@
|
||||
import java.time.LocalDateTime
|
||||
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
@@ -16,8 +18,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 675
|
||||
versionName = '7.6.2'
|
||||
versionCode = 700
|
||||
versionName = '8.0-a1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -37,11 +39,23 @@ android {
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
nightly {
|
||||
initWith release
|
||||
applicationIdSuffix = '.nightly'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += [
|
||||
'META-INF/README.md',
|
||||
'META-INF/NOTICE.md'
|
||||
]
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
main.java.srcDirs += 'src/main/kotlin/'
|
||||
@@ -59,12 +73,13 @@ android {
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||
]
|
||||
}
|
||||
lint {
|
||||
abortOnError true
|
||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled'
|
||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources true
|
||||
@@ -73,6 +88,15 @@ android {
|
||||
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 {
|
||||
compileDebugKotlin {
|
||||
@@ -82,86 +106,92 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:6f7e1fcfb2') {
|
||||
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'
|
||||
}
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
||||
coreLibraryDesugaring libs.desugar.jdk.libs
|
||||
implementation libs.kotlin.stdlib
|
||||
implementation libs.kotlinx.coroutines.android
|
||||
implementation libs.kotlinx.coroutines.guava
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.2'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.4'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
|
||||
implementation 'androidx.webkit:webkit:1.11.0'
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.androidx.core
|
||||
implementation libs.androidx.activity
|
||||
implementation libs.androidx.fragment
|
||||
implementation libs.androidx.transition
|
||||
implementation libs.androidx.collection
|
||||
implementation libs.lifecycle.viewmodel
|
||||
implementation libs.lifecycle.service
|
||||
implementation libs.lifecycle.process
|
||||
implementation libs.androidx.constraintlayout
|
||||
implementation libs.androidx.swiperefreshlayout
|
||||
implementation libs.androidx.recyclerview
|
||||
implementation libs.androidx.viewpager2
|
||||
implementation libs.androidx.preference
|
||||
implementation libs.androidx.biometric
|
||||
implementation libs.material
|
||||
implementation libs.androidx.lifecycle.common.java8
|
||||
implementation libs.androidx.webkit
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.1'
|
||||
//noinspection GradleDependency
|
||||
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 libs.androidx.work.runtime
|
||||
implementation libs.guava
|
||||
|
||||
implementation 'androidx.room:room-runtime:2.6.1'
|
||||
implementation 'androidx.room:room-ktx:2.6.1'
|
||||
ksp 'androidx.room:room-compiler:2.6.1'
|
||||
implementation libs.androidx.room.runtime
|
||||
implementation libs.androidx.room.ktx
|
||||
ksp libs.androidx.room.compiler
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||
implementation 'com.squareup.okio:okio:3.9.1'
|
||||
implementation libs.okhttp
|
||||
implementation libs.okhttp.tls
|
||||
implementation libs.okhttp.dnsoverhttps
|
||||
implementation libs.okio
|
||||
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
implementation libs.adapterdelegates
|
||||
implementation libs.adapterdelegates.viewbinding
|
||||
|
||||
implementation 'com.google.dagger:hilt-android:2.52'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.52'
|
||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
||||
implementation libs.hilt.android
|
||||
kapt libs.hilt.compiler
|
||||
implementation libs.androidx.hilt.work
|
||||
kapt libs.androidx.hilt.compiler
|
||||
|
||||
implementation 'io.coil-kt:coil-base:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:e04098de68'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
implementation libs.coil.core
|
||||
implementation libs.coil.network
|
||||
implementation libs.coil.gif
|
||||
implementation libs.coil.svg
|
||||
implementation libs.avif.decoder
|
||||
implementation libs.ssiv
|
||||
implementation libs.disk.lru.cache
|
||||
implementation libs.markwon
|
||||
|
||||
implementation 'ch.acra:acra-http:5.11.4'
|
||||
implementation 'ch.acra:acra-dialog:5.11.4'
|
||||
implementation libs.acra.http
|
||||
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 'com.github.Koitharu:WorkInspector:5778dd1747'
|
||||
debugImplementation libs.leakcanary.android
|
||||
debugImplementation libs.workinspector
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.json
|
||||
testImplementation libs.kotlinx.coroutines.test
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.6.1'
|
||||
androidTestImplementation 'androidx.test:rules:1.6.1'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
||||
androidTestImplementation libs.androidx.runner
|
||||
androidTestImplementation libs.androidx.rules
|
||||
androidTestImplementation libs.androidx.test.core
|
||||
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 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||
androidTestImplementation libs.androidx.room.testing
|
||||
androidTestImplementation libs.moshi.kotlin
|
||||
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52'
|
||||
androidTestImplementation libs.hilt.android.testing
|
||||
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.openjsse.**
|
||||
-dontwarn com.google.j2objc.annotations.**
|
||||
-dontwarn coil3.PlatformContext
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||
@@ -26,3 +27,4 @@
|
||||
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
||||
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
||||
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
||||
-keep class org.acra.sender.JobSenderService
|
||||
|
||||
@@ -9,11 +9,12 @@ import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.os.strictmode.Violation
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import kotlin.math.absoluteValue
|
||||
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||
|
||||
@@ -42,7 +43,7 @@ class StrictModeNotifier(
|
||||
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||
|
||||
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setSmallIcon(R.drawable.ic_bug)
|
||||
.setContentTitle(context.getString(R.string.strict_mode))
|
||||
.setContentText(violation.message)
|
||||
.setStyle(
|
||||
@@ -51,7 +52,15 @@ class StrictModeNotifier(
|
||||
.setSummaryText(violation.message)
|
||||
.bigText(violation.stackTraceToString()),
|
||||
).setShowWhen(true)
|
||||
.setContentIntent(ErrorReporterReceiver.getPendingIntent(context, violation))
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
0,
|
||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
.setAutoCancel(true)
|
||||
.setGroup(CHANNEL_ID)
|
||||
.build()
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
||||
@@ -12,8 +13,11 @@ class CurlLoggingInterceptor(
|
||||
|
||||
private val escapeRegex = Regex("([\\[\\]\"])")
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
|
||||
logRequest(it.networkResponse?.request ?: it.request)
|
||||
}
|
||||
|
||||
private fun logRequest(request: Request) {
|
||||
var isCompressed = false
|
||||
|
||||
val curlCmd = StringBuilder()
|
||||
@@ -46,16 +50,11 @@ class CurlLoggingInterceptor(
|
||||
|
||||
log("---cURL (" + request.url + ")")
|
||||
log(curlCmd.toString())
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun String.escape() = replace(escapeRegex) { match ->
|
||||
"\\" + match.value
|
||||
}
|
||||
// .replace("\"", "\\\"")
|
||||
// .replace("[", "\\[")
|
||||
// .replace("]", "\\]")
|
||||
|
||||
private fun log(msg: String) {
|
||||
Log.d("CURL", msg)
|
||||
|
||||
15
app/src/debug/res/drawable-anydpi-v24/ic_bug.xml
Normal file
15
app/src/debug/res/drawable-anydpi-v24/ic_bug.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="0.98150784"
|
||||
android:scaleY="0.98150784"
|
||||
android:translateX="0.22190611"
|
||||
android:translateY="-0.2688478">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
||||
</group>
|
||||
</vector>
|
||||
BIN
app/src/debug/res/drawable-hdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-hdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
BIN
app/src/debug/res/drawable-mdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-mdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 B |
BIN
app/src/debug/res/drawable-xhdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-xhdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 480 B |
BIN
app/src/debug/res/drawable-xxhdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-xxhdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 792 B |
@@ -46,7 +46,7 @@
|
||||
android:allowBackup="true"
|
||||
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -266,19 +266,30 @@
|
||||
tools:node="merge" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="@string/local_manga_processing" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="@string/periodic_backups" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" />
|
||||
android:foregroundServiceType="dataSync"
|
||||
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.local.ui.ImportService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="@string/importing_manga" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||
android:label="@string/manga_shelf"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
|
||||
android:label="@string/recent_manga"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
|
||||
@@ -315,7 +326,8 @@
|
||||
</service>
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:label="@string/prefetch_content" />
|
||||
|
||||
<provider
|
||||
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
|
||||
@@ -394,7 +406,7 @@
|
||||
android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
|
||||
|
||||
<activity-alias
|
||||
android:name="org.koitharu.kotatsu.details.ui.DetailsBYLinkActivity"
|
||||
android:name="org.koitharu.kotatsu.details.ui.DetailsByLinkActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity="org.koitharu.kotatsu.details.ui.DetailsActivity">
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ class AutoFixUseCase @Inject constructor(
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
||||
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
|
||||
.getDetailsSafe()
|
||||
val seed = checkNotNull(
|
||||
mangaDataRepository.findMangaById(mangaId, withChapters = true),
|
||||
) { "Manga $mangaId not found" }.getDetailsSafe()
|
||||
if (seed.isHealthy()) {
|
||||
return seed to null // no fix required
|
||||
}
|
||||
|
||||
@@ -5,9 +5,16 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
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 org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
@@ -19,8 +26,9 @@ import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
@@ -74,7 +82,7 @@ fun alternativeAD(
|
||||
.placeholder(R.drawable.ic_web)
|
||||
.fallback(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)))
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
@@ -84,8 +92,7 @@ fun alternativeAD(
|
||||
defaultPlaceholders(context)
|
||||
transformations(TrimTransformation())
|
||||
allowRgb565(true)
|
||||
tag(item.manga)
|
||||
source(item.manga.source)
|
||||
mangaExtra(item.manga)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
@@ -22,7 +19,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||
@@ -30,8 +26,6 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -65,7 +59,7 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
viewModel.content.observe(this, listAdapter)
|
||||
viewModel.onMigrated.observeEvent(this) {
|
||||
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||
startActivity(DetailsActivity.newIntent(this, it))
|
||||
router.openDetails(it)
|
||||
finishAfterTransition()
|
||||
}
|
||||
}
|
||||
@@ -82,16 +76,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
|
||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||
when (view.id) {
|
||||
R.id.chip_source -> startActivity(
|
||||
MangaListActivity.newIntent(
|
||||
this,
|
||||
item.manga.source,
|
||||
MangaListFilter(query = viewModel.manga.title),
|
||||
),
|
||||
)
|
||||
|
||||
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
|
||||
R.id.button_migrate -> confirmMigration(item.manga)
|
||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||
else -> router.openDetails(item.manga)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +101,4 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
||||
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
@@ -40,7 +40,7 @@ class AlternativesViewModel @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
|
||||
|
||||
val onMigrated = MutableEventFlow<Manga>()
|
||||
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
||||
|
||||
@@ -10,22 +10,22 @@ import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
@@ -47,25 +47,21 @@ class AutoFixService : CoroutineIntentService() {
|
||||
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))
|
||||
startForeground(startId)
|
||||
try {
|
||||
for (mangaId in ids) {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
startForeground(this)
|
||||
for (mangaId in ids) {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
} finally {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) {
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
@@ -73,7 +69,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground(startId: Int) {
|
||||
private fun startForeground(jobContext: IntentJobContext) {
|
||||
val title = applicationContext.getString(R.string.fixing_manga)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||
.setName(title)
|
||||
@@ -97,12 +93,11 @@ class AutoFixService : CoroutineIntentService() {
|
||||
.addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
getCancelIntent(startId),
|
||||
jobContext.getCancelIntent(),
|
||||
)
|
||||
.build()
|
||||
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
jobContext.setForeground(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
@@ -121,12 +116,12 @@ class AutoFixService : CoroutineIntentService() {
|
||||
coil.execute(
|
||||
ImageRequest.Builder(applicationContext)
|
||||
.data(replacement.coverUrl)
|
||||
.tag(replacement.source)
|
||||
.mangaSourceExtra(replacement.source)
|
||||
.build(),
|
||||
).toBitmapOrNull(),
|
||||
)
|
||||
notification.setSubText(replacement.title)
|
||||
val intent = DetailsActivity.newIntent(applicationContext, replacement)
|
||||
val intent = AppRouter.detailsIntent(applicationContext, replacement)
|
||||
notification.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.graphics.Insets
|
||||
@@ -46,9 +44,4 @@ class AllBookmarksActivity :
|
||||
right = insets.right,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import coil.ImageLoader
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
||||
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.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
@@ -30,7 +32,6 @@ import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
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.adapter.ListHeaderClickListener
|
||||
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.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -115,26 +115,26 @@ class AllBookmarksFragment :
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
if (selectionController?.onItemClick(item.pageId) != true) {
|
||||
val intent = ReaderActivity.IntentBuilder(view.context)
|
||||
val intent = ReaderIntent.Builder(view.context)
|
||||
.bookmark(item)
|
||||
.incognito(true)
|
||||
.build()
|
||||
startActivity(intent)
|
||||
router.openReader(intent)
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListHeaderClick(item: ListHeader, view: View) {
|
||||
val manga = item.payload as? Manga ?: return
|
||||
startActivity(DetailsActivity.newIntent(view.context, manga))
|
||||
router.openDetails(manga)
|
||||
}
|
||||
|
||||
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 {
|
||||
return selectionController?.onItemContextClick(view, item.pageId) ?: false
|
||||
return selectionController?.onItemContextClick(view, item.pageId) == true
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
@@ -208,16 +208,4 @@ class AllBookmarksFragment :
|
||||
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
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.allowRgb565
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
@@ -29,9 +30,8 @@ fun bookmarkLargeAD(
|
||||
size(CoverSizeResolver(binding.imageViewThumb))
|
||||
defaultPlaceholders(context)
|
||||
allowRgb565(true)
|
||||
tag(item)
|
||||
bookmarkExtra(item)
|
||||
decodeRegion(item.scroll)
|
||||
source(item.manga.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
binding.progressView.setProgress(item.percent, false)
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.allowRgb565
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
||||
|
||||
// TODO check usages
|
||||
fun bookmarkListAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
@@ -28,9 +30,8 @@ fun bookmarkListAD(
|
||||
size(CoverSizeResolver(binding.imageViewThumb))
|
||||
defaultPlaceholders(context)
|
||||
allowRgb565(true)
|
||||
tag(item)
|
||||
bookmarkExtra(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 androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil3.ImageLoader
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
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.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.webkit.CookieManager
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
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.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -42,11 +38,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
@@ -59,7 +54,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
onTitleChanged(
|
||||
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
|
||||
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
|
||||
url,
|
||||
)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
@@ -80,14 +75,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
|
||||
R.id.action_browser -> {
|
||||
val url = viewBinding.webView.url?.toUriOrNull()
|
||||
if (url != null) {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = url
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, item.title))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
}
|
||||
if (!router.openExternalBrowser(viewBinding.webView.url.orEmpty(), item.title)) {
|
||||
Snackbar.make(viewBinding.webView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -136,17 +125,4 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,26 +9,28 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import coil.EventListener
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageRequest
|
||||
import coil3.EventListener
|
||||
import coil3.Extras
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class CaptchaNotifier(
|
||||
private val context: Context,
|
||||
) : EventListener {
|
||||
) : EventListener() {
|
||||
|
||||
fun notify(exception: CloudFlareProtectedException) {
|
||||
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
||||
return
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(context.getString(R.string.captcha_required))
|
||||
.setShowBadge(true)
|
||||
.setVibrationEnabled(false)
|
||||
@@ -37,13 +39,13 @@ class CaptchaNotifier(
|
||||
.build()
|
||||
manager.createNotificationChannel(channel)
|
||||
|
||||
val intent = CloudFlareActivity.newIntent(context, exception)
|
||||
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
|
||||
.setData(exception.url.toUri())
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setContentTitle(channel.name)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setDefaults(0)
|
||||
.setSmallIcon(R.drawable.ic_bot)
|
||||
.setGroup(GROUP_CAPTCHA)
|
||||
.setAutoCancel(true)
|
||||
.setVisibility(
|
||||
@@ -84,20 +86,19 @@ class CaptchaNotifier(
|
||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||
super.onError(request, result)
|
||||
val e = result.throwable
|
||||
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
|
||||
if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) {
|
||||
notify(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
|
||||
key = PARAM_IGNORE_CAPTCHA,
|
||||
value = true,
|
||||
memoryCacheKey = null,
|
||||
)
|
||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
||||
extras[ignoreCaptchaKey] = true
|
||||
}
|
||||
|
||||
val ignoreCaptchaKey = Extras.Key(false)
|
||||
|
||||
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
|
||||
private const val CHANNEL_ID = "captcha"
|
||||
private const val TAG = CHANNEL_ID
|
||||
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.webkit.CookieManager
|
||||
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.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -19,19 +16,18 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.yield
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
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.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -61,12 +57,11 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
return
|
||||
}
|
||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
||||
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
|
||||
viewBinding.webView.webViewClient = cfClient
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||
onBackPressedDispatcher.addCallback(it)
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
if (savedInstanceState == null) {
|
||||
onTitleChanged(getString(R.string.loading_), url)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
@@ -139,7 +134,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
|
||||
override fun onCheckPassed() {
|
||||
pendingResult = RESULT_OK
|
||||
val source = intent?.getStringExtra(ARG_SOURCE)
|
||||
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
|
||||
if (source != null) {
|
||||
CaptchaNotifier(this).dismiss(MangaSource(source))
|
||||
}
|
||||
@@ -175,45 +170,22 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
|
||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
||||
cookieJar.removeCookies(url) { cookie ->
|
||||
val name = cookie.name
|
||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
||||
CloudFlareHelper.isCloudFlareCookie(cookie.name)
|
||||
}
|
||||
}
|
||||
|
||||
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
|
||||
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 {
|
||||
return resultCode == Activity.RESULT_OK
|
||||
return resultCode == RESULT_OK
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
|
||||
private const val CF_CLEARANCE = "cf_clearance"
|
||||
private const val LOOP_COUNTER = 3
|
||||
|
||||
class CloudFlareClient(
|
||||
@@ -50,8 +49,5 @@ class CloudFlareClient(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getClearance(): String? {
|
||||
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
|
||||
.find { it.name == CF_CLEARANCE }?.value
|
||||
}
|
||||
private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,21 @@ package org.koitharu.kotatsu.core
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.text.Html
|
||||
import androidx.collection.arraySetOf
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.work.WorkManager
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.util.DebugLogger
|
||||
import coil3.ImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
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.Module
|
||||
import dagger.Provides
|
||||
@@ -27,6 +32,9 @@ import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
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.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
@@ -43,7 +51,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.MangaPageKeyer
|
||||
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.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||
@@ -80,9 +87,7 @@ interface AppModule {
|
||||
@Singleton
|
||||
fun provideMangaDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): MangaDatabase {
|
||||
return MangaDatabase(context)
|
||||
}
|
||||
): MangaDatabase = MangaDatabase(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -93,6 +98,7 @@ interface AppModule {
|
||||
imageProxyInterceptor: ImageProxyInterceptor,
|
||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||
networkStateProvider: Provider<NetworkState>,
|
||||
): ImageLoader {
|
||||
val diskCacheFactory = {
|
||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||
@@ -104,36 +110,39 @@ interface AppModule {
|
||||
okHttpClientProvider.get().newBuilder().cache(null).build()
|
||||
}
|
||||
return ImageLoader.Builder(context)
|
||||
.okHttpClient { okHttpClientLazy.value }
|
||||
.interceptorDispatcher(Dispatchers.Default)
|
||||
.fetcherDispatcher(Dispatchers.Default)
|
||||
.decoderDispatcher(Dispatchers.IO)
|
||||
.transformationDispatcher(Dispatchers.Default)
|
||||
.interceptorCoroutineContext(Dispatchers.Default)
|
||||
.diskCache(diskCacheFactory)
|
||||
.respectCacheHeaders(false)
|
||||
.networkObserverEnabled(false)
|
||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||
.allowRgb565(context.isLowRamDevice())
|
||||
.eventListener(CaptchaNotifier(context))
|
||||
.components(
|
||||
ComponentRegistry.Builder()
|
||||
.add(SvgDecoder.Factory())
|
||||
.add(CbzFetcher.Factory())
|
||||
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
||||
.add(MangaPageKeyer())
|
||||
.add(pageFetcherFactory)
|
||||
.add(imageProxyInterceptor)
|
||||
.add(coverRestoreInterceptor)
|
||||
.build(),
|
||||
).build()
|
||||
.components {
|
||||
add(
|
||||
OkHttpNetworkFetcherFactory(
|
||||
callFactory = okHttpClientLazy::value,
|
||||
connectivityChecker = { networkStateProvider.get() },
|
||||
),
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(AnimatedImageDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
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
|
||||
fun provideSearchSuggestions(
|
||||
@ApplicationContext context: Context,
|
||||
): SearchRecentSuggestions {
|
||||
return MangaSuggestionsProvider.createSuggestions(context)
|
||||
}
|
||||
): SearchRecentSuggestions = MangaSuggestionsProvider.createSuggestions(context)
|
||||
|
||||
@Provides
|
||||
@ElementsIntoSet
|
||||
|
||||
@@ -78,6 +78,9 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (ACRA.isACRASenderServiceProcess()) {
|
||||
return
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||
// TLS 1.3 support for Android < 10
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.net.Uri
|
||||
import android.os.BadParcelableException
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
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.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.report
|
||||
@@ -15,20 +16,19 @@ import org.koitharu.kotatsu.core.util.ext.report
|
||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_ERROR = "err"
|
||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
||||
|
||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||
intent.setAction(ACTION_REPORT)
|
||||
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)
|
||||
} catch (e: BadParcelableException) {
|
||||
e.printStackTraceDebug()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
|
||||
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -130,7 +130,7 @@ class BackupRepository @Inject constructor(
|
||||
|
||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
@@ -150,7 +150,7 @@ class BackupRepository @Inject constructor(
|
||||
|
||||
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
||||
result += runCatchingCancellable {
|
||||
db.getFavouriteCategoriesDao().upsert(category)
|
||||
@@ -161,7 +161,7 @@ class BackupRepository @Inject constructor(
|
||||
|
||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
@@ -181,7 +181,7 @@ class BackupRepository @Inject constructor(
|
||||
|
||||
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = item.getJSONArray("tags").mapJSON {
|
||||
@@ -203,7 +203,7 @@ class BackupRepository @Inject constructor(
|
||||
|
||||
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val source = JsonDeserializer(item).toMangaSourceEntity()
|
||||
result += runCatchingCancellable {
|
||||
db.getSourcesDao().upsert(source)
|
||||
@@ -214,7 +214,7 @@ class BackupRepository @Inject constructor(
|
||||
|
||||
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
result += runCatchingCancellable {
|
||||
settings.upsertAll(JsonDeserializer(item).toMap())
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Closeable
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import java.io.File
|
||||
import java.util.EnumSet
|
||||
import java.util.zip.ZipException
|
||||
@@ -36,13 +33,9 @@ class BackupZipInput private constructor(val file: File) : Closeable {
|
||||
zipFile.close()
|
||||
}
|
||||
|
||||
fun cleanupAsync() {
|
||||
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
||||
runCatching {
|
||||
closeQuietly()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
fun closeAndDelete() {
|
||||
closeQuietly()
|
||||
file.delete()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -55,7 +48,7 @@ class BackupZipInput private constructor(val file: File) : Closeable {
|
||||
throw BadBackupFormatException(null)
|
||||
}
|
||||
res
|
||||
} catch (exception: Exception) {
|
||||
} catch (exception: Throwable) {
|
||||
res?.closeQuietly()
|
||||
throw if (exception is ZipException) {
|
||||
BadBackupFormatException(exception)
|
||||
|
||||
@@ -5,10 +5,12 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.Deflater
|
||||
|
||||
@@ -27,20 +29,32 @@ class BackupZipOutput(val file: File) : Closeable {
|
||||
override fun close() {
|
||||
output.close()
|
||||
}
|
||||
}
|
||||
|
||||
const val DIR_BACKUPS = "backups"
|
||||
companion object {
|
||||
|
||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
const val DIR_BACKUPS = "backups"
|
||||
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
||||
|
||||
fun generateFileName(context: Context) = buildString {
|
||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(dateTimeFormat.format(Date()))
|
||||
append(".bk.zip")
|
||||
}
|
||||
|
||||
fun parseBackupDateTime(fileName: String): Date? = try {
|
||||
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
||||
} catch (e: ParseException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
BackupZipOutput(File(dir, generateFileName(context)))
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||
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.MangaSourcesDao
|
||||
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
|
||||
import org.koitharu.kotatsu.core.db.dao.TagsDao
|
||||
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.MangaPrefsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
@@ -36,6 +38,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -63,14 +66,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 23
|
||||
const val DATABASE_VERSION = 24
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, ChapterEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class,
|
||||
TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class,
|
||||
MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||
],
|
||||
version = DATABASE_VERSION,
|
||||
)
|
||||
@@ -103,6 +106,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
abstract fun getStatsDao(): StatsDao
|
||||
|
||||
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
||||
|
||||
abstract fun getChaptersDao(): ChaptersDao
|
||||
}
|
||||
|
||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
@@ -128,6 +133,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration20To21(),
|
||||
Migration21To22(),
|
||||
Migration22To23(),
|
||||
Migration23To24(),
|
||||
)
|
||||
|
||||
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_MANGA_TAGS = "manga_tags"
|
||||
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")
|
||||
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
|
||||
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
||||
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
||||
@@ -55,6 +58,19 @@ abstract class MangaDao {
|
||||
@Delete
|
||||
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
|
||||
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
||||
upsert(manga)
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.room.Upsert
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
@@ -61,21 +60,11 @@ abstract class MangaSourcesDao {
|
||||
@Query("SELECT * FROM sources WHERE pinned = 1")
|
||||
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
||||
|
||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
|
||||
observeImpl(getQuery(enabledOnly, order))
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy")
|
||||
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)
|
||||
}
|
||||
suspend fun findAll(enabledOnly: Boolean, order: SourcesSortOrder): List<MangaSourceEntity> =
|
||||
findAllImpl(getQuery(enabledOnly, order))
|
||||
|
||||
@Transaction
|
||||
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
||||
@@ -101,6 +90,17 @@ abstract class MangaSourcesDao {
|
||||
@RawQuery
|
||||
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) {
|
||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||
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 name: 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,
|
||||
)
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.db.entity
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||
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.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
@@ -21,7 +22,7 @@ fun Collection<TagEntity>.toMangaTags() = mapToSet(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,
|
||||
title = this.title,
|
||||
altTitle = this.altTitle,
|
||||
@@ -35,12 +36,27 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||
author = this.author,
|
||||
source = MangaSource(this.source),
|
||||
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 ChapterEntity.toMangaChapter() = MangaChapter(
|
||||
id = chapterId,
|
||||
name = name,
|
||||
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
|
||||
|
||||
fun Manga.toEntity() = MangaEntity(
|
||||
@@ -67,6 +83,22 @@ fun MangaTag.toEntity() = TagEntity(
|
||||
|
||||
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||
|
||||
fun Iterable<IndexedValue<MangaChapter>>.toEntities(mangaId: Long) = map { (index, chapter) ->
|
||||
ChapterEntity(
|
||||
chapterId = chapter.id,
|
||||
mangaId = mangaId,
|
||||
name = chapter.name,
|
||||
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
|
||||
|
||||
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
|
||||
@@ -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 )")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
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
|
||||
|
||||
class NoDataReceivedException(
|
||||
url: String,
|
||||
val url: String,
|
||||
) : 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,8 +6,8 @@ import androidx.core.util.Consumer
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
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.isSerializable
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
|
||||
class DialogErrorObserver(
|
||||
@@ -31,10 +31,10 @@ class DialogErrorObserver(
|
||||
if (canResolve(value)) {
|
||||
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
||||
} else if (value is ParseException) {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
val router = router()
|
||||
if (router != null && value.isSerializable()) {
|
||||
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.core.util.Consumer
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -11,6 +12,7 @@ import androidx.lifecycle.coroutineScope
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.isActive
|
||||
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.viewLifecycleScope
|
||||
|
||||
@@ -33,6 +35,8 @@ abstract class ErrorObserver(
|
||||
return resolver != null && ExceptionResolver.canResolve(error)
|
||||
}
|
||||
|
||||
protected fun router() = fragment?.router ?: (activity as? FragmentActivity)?.router
|
||||
|
||||
private fun isAlive(): Boolean {
|
||||
return when {
|
||||
fragment != null -> fragment.view != null
|
||||
@@ -44,7 +48,7 @@ abstract class ErrorObserver(
|
||||
protected fun resolve(error: Throwable) {
|
||||
if (isAlive()) {
|
||||
lifecycleScope.launch {
|
||||
val isResolved = resolver?.resolve(error) ?: false
|
||||
val isResolved = resolver?.resolve(error) == true
|
||||
if (isActive) {
|
||||
onResolved?.accept(isResolved)
|
||||
}
|
||||
|
||||
@@ -5,19 +5,20 @@ import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultCaller
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
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.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
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.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||
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.scrobbling.common.domain.ScrobblerAuthRequiredException
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import javax.inject.Provider
|
||||
@@ -49,8 +49,8 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
|
||||
fun showDetails(e: Throwable, url: String?) {
|
||||
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router()?.showErrorDialog(e, url)
|
||||
}
|
||||
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
@@ -63,9 +63,7 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
}
|
||||
|
||||
is ProxyConfigException -> {
|
||||
host.withContext {
|
||||
startActivity(SettingsActivity.newProxySettingsIntent(this))
|
||||
}
|
||||
host.router()?.openProxySettings()
|
||||
false
|
||||
}
|
||||
|
||||
@@ -85,9 +83,7 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure {
|
||||
showDetails(it, null)
|
||||
}
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -106,12 +102,12 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) = host.withContext {
|
||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router()?.openBrowser(url, null, null)
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) = host.withContext {
|
||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router()?.openAlternatives(manga)
|
||||
}
|
||||
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
@@ -140,6 +136,12 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
getContext()?.apply(block)
|
||||
}
|
||||
|
||||
private fun Host.router(): AppRouter? = when (this) {
|
||||
is FragmentActivity -> router
|
||||
is Fragment -> router
|
||||
else -> null
|
||||
}
|
||||
|
||||
interface Host : ActivityResultCaller {
|
||||
|
||||
fun getChildFragmentManager(): FragmentManager
|
||||
|
||||
@@ -5,8 +5,8 @@ import androidx.core.util.Consumer
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
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.isSerializable
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
|
||||
@@ -32,10 +32,10 @@ class SnackbarErrorObserver(
|
||||
resolve(value)
|
||||
}
|
||||
} else if (value is ParseException) {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
val router = router()
|
||||
if (router != null && value.isSerializable()) {
|
||||
snackbar.setAction(R.string.details) {
|
||||
ErrorDetailsDialog.show(fm, value, value.url)
|
||||
router.showErrorDialog(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.github
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -9,6 +11,7 @@ import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||
import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -22,22 +25,29 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive"
|
||||
private const val BUILD_TYPE_RELEASE = "release"
|
||||
|
||||
@Singleton
|
||||
class AppUpdateRepository @Inject constructor(
|
||||
private val appValidator: AppValidator,
|
||||
private val settings: AppSettings,
|
||||
@BaseHttpClient private val okHttp: OkHttpClient,
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
fun observeAvailableUpdate() = availableUpdate.asStateFlow()
|
||||
|
||||
suspend fun getAvailableVersions(): List<AppVersion> {
|
||||
val request = Request.Builder()
|
||||
.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()
|
||||
return jsonArray.mapJSONNotNull { json ->
|
||||
val asset = json.optJSONArray("assets")?.find { jo ->
|
||||
@@ -74,8 +84,9 @@ class AppUpdateRepository @Inject constructor(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@Suppress("KotlinConstantConditions")
|
||||
fun isUpdateSupported(): Boolean {
|
||||
return BuildConfig.DEBUG || appValidator.isOriginalApp
|
||||
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp
|
||||
}
|
||||
|
||||
suspend fun getCurrentVersionChangelog(): String? {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.github
|
||||
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.core.util.ext.digits
|
||||
import java.util.Locale
|
||||
|
||||
data class VersionId(
|
||||
val major: Int,
|
||||
@@ -43,6 +44,16 @@ val VersionId.isStable: Boolean
|
||||
get() = variantType.isEmpty()
|
||||
|
||||
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 variant = versionName.substringAfterLast('-', "")
|
||||
return VersionId(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.aomedia.avif.android.AvifDecoder
|
||||
import org.aomedia.avif.android.AvifDecoder.Info
|
||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||
|
||||
class AvifImageDecoder(
|
||||
private val source: ImageSource,
|
||||
private val options: Options,
|
||||
) : Decoder {
|
||||
|
||||
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||
val bytes = source.source().use {
|
||||
it.inputStream().toByteBuffer()
|
||||
}
|
||||
val info = Info()
|
||||
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
||||
throw ImageDecodeException(
|
||||
null,
|
||||
"avif",
|
||||
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||
)
|
||||
}
|
||||
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(null, "avif")
|
||||
}
|
||||
DecodeResult(
|
||||
image = bitmap.asImage(),
|
||||
isSampled = false,
|
||||
)
|
||||
}
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
|
||||
override fun create(
|
||||
result: SourceFetchResult,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Decoder? = if (isApplicable(result)) {
|
||||
AvifImageDecoder(result.source, options)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
override fun equals(other: Any?) = other is Factory
|
||||
|
||||
override fun hashCode() = javaClass.hashCode()
|
||||
|
||||
private fun isApplicable(result: SourceFetchResult): Boolean {
|
||||
return result.mimeType == "image/avif"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import org.aomedia.avif.android.AvifDecoder
|
||||
import org.aomedia.avif.android.AvifDecoder.Info
|
||||
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.toMimeTypeOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object BitmapDecoderCompat {
|
||||
|
||||
private const val FORMAT_AVIF = "avif"
|
||||
|
||||
@Blocking
|
||||
fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) {
|
||||
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
|
||||
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
|
||||
} else {
|
||||
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format)
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun decode(stream: InputStream, type: MimeType?, isMutable: Boolean = false): Bitmap {
|
||||
val format = type?.subtype
|
||||
if (format == FORMAT_AVIF) {
|
||||
return decodeAvif(stream.toByteBuffer())
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
val opts = BitmapFactory.Options()
|
||||
opts.inMutable = isMutable
|
||||
return checkBitmapNotNull(BitmapFactory.decodeStream(stream, null, opts), format)
|
||||
}
|
||||
val byteBuffer = stream.toByteBuffer()
|
||||
return if (AvifDecoder.isAvifImage(byteBuffer)) {
|
||||
decodeAvif(byteBuffer)
|
||||
} else {
|
||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer), DecoderConfigListener(isMutable))
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun probeMimeType(file: File): MimeType? {
|
||||
return MimeTypes.probeMimeType(file) ?: detectBitmapType(file)
|
||||
}
|
||||
|
||||
@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 =
|
||||
bitmap ?: throw ImageDecodeException(null, format)
|
||||
|
||||
private fun decodeAvif(bytes: ByteBuffer): Bitmap {
|
||||
val info = Info()
|
||||
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
|
||||
throw ImageDecodeException(
|
||||
null,
|
||||
FORMAT_AVIF,
|
||||
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
|
||||
)
|
||||
}
|
||||
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
|
||||
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
|
||||
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
|
||||
bitmap.recycle()
|
||||
throw ImageDecodeException(null, FORMAT_AVIF)
|
||||
}
|
||||
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,48 @@
|
||||
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)
|
||||
SourceFetchResult(
|
||||
source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)),
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,39 @@
|
||||
package org.koitharu.kotatsu.core.ui.image
|
||||
package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DecodeResult
|
||||
import coil.decode.DecodeUtils
|
||||
import coil.decode.Decoder
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.SourceResult
|
||||
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 coil3.Extras
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
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 kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class RegionBitmapDecoder(
|
||||
private val source: ImageSource,
|
||||
private val options: Options,
|
||||
private val parallelismLock: Semaphore,
|
||||
) : Decoder {
|
||||
|
||||
override suspend fun decode() = parallelismLock.withPermit {
|
||||
runInterruptible { BitmapFactory.Options().decode() }
|
||||
}
|
||||
|
||||
private fun BitmapFactory.Options.decode(): DecodeResult {
|
||||
override suspend fun decode(): DecodeResult = runInterruptible {
|
||||
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(source.source().inputStream())
|
||||
} else {
|
||||
@@ -41,13 +41,14 @@ class RegionBitmapDecoder(
|
||||
BitmapRegionDecoder.newInstance(source.source().inputStream(), false)
|
||||
}
|
||||
checkNotNull(regionDecoder)
|
||||
val bitmapOptions = BitmapFactory.Options()
|
||||
try {
|
||||
val rect = configureScale(regionDecoder.width, regionDecoder.height)
|
||||
configureConfig()
|
||||
val bitmap = regionDecoder.decodeRegion(rect, this)
|
||||
val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height)
|
||||
bitmapOptions.configureConfig()
|
||||
val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions)
|
||||
bitmap.density = options.context.resources.displayMetrics.densityDpi
|
||||
return DecodeResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
DecodeResult(
|
||||
image = bitmap.asImage(),
|
||||
isSampled = true,
|
||||
)
|
||||
} finally {
|
||||
@@ -55,29 +56,6 @@ class RegionBitmapDecoder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun BitmapFactory.Options.configureConfig() {
|
||||
var config = options.config
|
||||
|
||||
inMutable = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
|
||||
inPreferredColorSpace = options.colorSpace
|
||||
}
|
||||
inPremultiplied = options.premultipliedAlpha
|
||||
|
||||
// Decode the image as RGB_565 as an optimization if allowed.
|
||||
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
|
||||
config = Bitmap.Config.RGB_565
|
||||
}
|
||||
|
||||
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
|
||||
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
|
||||
config = Bitmap.Config.RGBA_F16
|
||||
}
|
||||
|
||||
inPreferredConfig = config
|
||||
}
|
||||
|
||||
/** Compute and set the scaling properties for [BitmapFactory.Options]. */
|
||||
private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect {
|
||||
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
|
||||
@@ -91,7 +69,7 @@ class RegionBitmapDecoder(
|
||||
} else {
|
||||
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) {
|
||||
rect.offsetTo(
|
||||
(srcWidth - rect.width()) / 2,
|
||||
@@ -123,7 +101,7 @@ class RegionBitmapDecoder(
|
||||
)
|
||||
|
||||
// Only upscale the image if the options require an exact size.
|
||||
if (options.allowInexactSize) {
|
||||
if (options.precision == Precision.INEXACT) {
|
||||
scale = scale.coerceAtMost(1.0)
|
||||
}
|
||||
|
||||
@@ -142,19 +120,36 @@ class RegionBitmapDecoder(
|
||||
return rect
|
||||
}
|
||||
|
||||
class Factory(
|
||||
maxParallelism: Int = DEFAULT_MAX_PARALLELISM,
|
||||
) : Decoder.Factory {
|
||||
private fun BitmapFactory.Options.configureConfig() {
|
||||
var config = options.bitmapConfig
|
||||
|
||||
@Suppress("NEWER_VERSION_IN_SINCE_KOTLIN")
|
||||
@SinceKotlin("999.9") // Only public in Java.
|
||||
constructor() : this()
|
||||
inMutable = false
|
||||
|
||||
private val parallelismLock = Semaphore(maxParallelism)
|
||||
|
||||
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder {
|
||||
return RegionBitmapDecoder(result.source, options, parallelismLock)
|
||||
if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
|
||||
inPreferredColorSpace = options.colorSpace
|
||||
}
|
||||
inPremultiplied = options.premultipliedAlpha
|
||||
|
||||
// Decode the image as RGB_565 as an optimization if allowed.
|
||||
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
|
||||
config = Bitmap.Config.RGB_565
|
||||
}
|
||||
|
||||
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
|
||||
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
|
||||
config = Bitmap.Config.RGBA_F16
|
||||
}
|
||||
|
||||
inPreferredConfig = config
|
||||
}
|
||||
|
||||
object Factory : Decoder.Factory {
|
||||
|
||||
override fun create(
|
||||
result: SourceFetchResult,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Decoder = RegionBitmapDecoder(result.source, options)
|
||||
|
||||
override fun equals(other: Any?) = other is Factory
|
||||
|
||||
@@ -163,9 +158,8 @@ class RegionBitmapDecoder(
|
||||
|
||||
companion object {
|
||||
|
||||
const val PARAM_SCROLL = "scroll"
|
||||
const val SCROLL_UNDEFINED = -1
|
||||
private const val DEFAULT_MAX_PARALLELISM = 4
|
||||
val regionScrollKey = Extras.Key(SCROLL_UNDEFINED)
|
||||
|
||||
private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
|
||||
return if (isOriginal) original() else width.toPx(scale)
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.io
|
||||
|
||||
import java.io.OutputStream
|
||||
import java.util.Objects
|
||||
|
||||
class NullOutputStream : OutputStream() {
|
||||
|
||||
override fun write(b: Int) = Unit
|
||||
|
||||
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||
Objects.checkFromIndexSize(off, len, b.size)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.util.formatSimple
|
||||
import org.koitharu.kotatsu.parsers.util.findById
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -29,8 +29,6 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||
@JvmName("chaptersIds")
|
||||
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
|
||||
|
||||
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
|
||||
|
||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||
if (size <= 1) {
|
||||
return size
|
||||
@@ -84,10 +82,6 @@ val Demographic.titleResId: Int
|
||||
Demographic.NONE -> R.string.none
|
||||
}
|
||||
|
||||
fun Manga.findChapter(id: Long): MangaChapter? {
|
||||
return chapters?.findById(id)
|
||||
}
|
||||
|
||||
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
val ch = chapters
|
||||
if (ch.isNullOrEmpty()) {
|
||||
@@ -136,12 +130,6 @@ val Manga.appUrl: Uri
|
||||
.appendQueryParameter("url", url)
|
||||
.build()
|
||||
|
||||
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
|
||||
number.formatSimple()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun Manga.chaptersCount(): Int {
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return 0
|
||||
|
||||
@@ -2,11 +2,16 @@ package org.koitharu.kotatsu.core.model
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.ImageSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.text.style.SuperscriptSpan
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.inSpans
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
@@ -100,3 +105,16 @@ fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
||||
) {
|
||||
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,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@Parcelize
|
||||
data class ParcelableManga(
|
||||
val manga: Manga,
|
||||
private val withDescription: Boolean = true,
|
||||
) : Parcelable {
|
||||
|
||||
companion object : Parceler<ParcelableManga> {
|
||||
@@ -27,7 +28,7 @@ data class ParcelableManga(
|
||||
ParcelCompat.writeBoolean(parcel, isNsfw)
|
||||
parcel.writeString(coverUrl)
|
||||
parcel.writeString(largeCoverUrl)
|
||||
parcel.writeString(description)
|
||||
parcel.writeString(description.takeIf { withDescription })
|
||||
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||
parcel.writeSerializable(state)
|
||||
parcel.writeString(author)
|
||||
@@ -52,6 +53,7 @@ data class ParcelableManga(
|
||||
chapters = null,
|
||||
source = MangaSource(parcel.readString()),
|
||||
),
|
||||
withDescription = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
602
app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt
Normal file
602
app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt
Normal file
@@ -0,0 +1,602 @@
|
||||
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.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import androidx.annotation.CheckResult
|
||||
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 org.koitharu.kotatsu.BuildConfig
|
||||
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.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.ReaderMode
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.mapToArray
|
||||
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.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.util.isNullOrEmpty
|
||||
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.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
|
||||
|
||||
class AppRouter private constructor(
|
||||
private val activity: FragmentActivity?,
|
||||
private val fragment: Fragment?,
|
||||
) {
|
||||
|
||||
constructor(activity: FragmentActivity) : this(activity, null)
|
||||
|
||||
constructor(fragment: Fragment) : this(null, fragment)
|
||||
|
||||
/** Activities **/
|
||||
|
||||
fun openList(source: MangaSource, filter: MangaListFilter?) {
|
||||
startActivity(listIntent(contextOrNull() ?: return, source, filter))
|
||||
}
|
||||
|
||||
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)))
|
||||
|
||||
fun openSearch(query: String) {
|
||||
startActivity(
|
||||
Intent(contextOrNull() ?: return, SearchActivity::class.java)
|
||||
.putExtra(KEY_QUERY, query),
|
||||
)
|
||||
}
|
||||
|
||||
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query))
|
||||
|
||||
fun openDetails(manga: Manga) {
|
||||
startActivity(detailsIntent(contextOrNull() ?: return, manga))
|
||||
}
|
||||
|
||||
fun openDetails(mangaId: Long) {
|
||||
startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
|
||||
}
|
||||
|
||||
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 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()
|
||||
}
|
||||
|
||||
/** 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 fm = fragment?.parentFragmentManager ?: activity?.supportFragmentManager ?: return false
|
||||
val sheet = fm.findFragmentByTag(fragmentTag<WelcomeSheet>()) as? WelcomeSheet ?: return false
|
||||
sheet.dismissAllowingStateLoss()
|
||||
return true
|
||||
}
|
||||
|
||||
/** 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 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(),
|
||||
)
|
||||
}
|
||||
|
||||
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?): Intent =
|
||||
Intent(context, MangaListActivity::class.java)
|
||||
.setAction(ACTION_MANGA_EXPLORE)
|
||||
.putExtra(KEY_SOURCE, source.name)
|
||||
.apply {
|
||||
if (!filter.isNullOrEmpty()) {
|
||||
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
|
||||
}
|
||||
}
|
||||
|
||||
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.get(CommonHeaders.USER_AGENT)?.let {
|
||||
putExtra(KEY_USER_AGENT, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::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)
|
||||
}
|
||||
|
||||
const val KEY_EXCLUDE = "exclude"
|
||||
const val KEY_FILTER = "filter"
|
||||
const val KEY_ID = "id"
|
||||
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_SOURCE = "source"
|
||||
const val KEY_TAB = "tab"
|
||||
const val KEY_TITLE = "title"
|
||||
const val KEY_USER_AGENT = "user_agent"
|
||||
const val KEY_URL = "url"
|
||||
const val KEY_ERROR = "error"
|
||||
const val KEY_FILE = "file"
|
||||
const val KEY_INDEX = "index"
|
||||
const val KEY_DATA = "data"
|
||||
|
||||
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 fun Class<out Fragment>.fragmentTag() = name // TODO
|
||||
|
||||
private inline fun <reified F : Fragment> fragmentTag() = F::class.java.fragmentTag()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
package org.koitharu.kotatsu.core.nav
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
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.getParcelableExtraCompat
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -25,7 +26,7 @@ class MangaIntent private constructor(
|
||||
constructor(savedStateHandle: SavedStateHandle) : this(
|
||||
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
|
||||
id = savedStateHandle[KEY_ID] ?: ID_NONE,
|
||||
uri = savedStateHandle[BaseActivity.EXTRA_DATA],
|
||||
uri = savedStateHandle[AppRouter.KEY_DATA],
|
||||
)
|
||||
|
||||
constructor(args: Bundle?) : this(
|
||||
@@ -41,9 +42,6 @@ class MangaIntent private constructor(
|
||||
|
||||
const val ID_NONE = 0L
|
||||
|
||||
const val KEY_MANGA = "manga"
|
||||
const val KEY_ID = "id"
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class AppProxySelector(
|
||||
if (type == Proxy.Type.DIRECT) {
|
||||
return Proxy.NO_PROXY
|
||||
}
|
||||
if (address.isNullOrEmpty() || port == 0) {
|
||||
if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) {
|
||||
throw ProxyConfigException()
|
||||
}
|
||||
cachedProxy?.let {
|
||||
|
||||
@@ -2,41 +2,43 @@ package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import org.jsoup.Jsoup
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
||||
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
|
||||
class CloudFlareInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
|
||||
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
|
||||
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
|
||||
} ?: return response
|
||||
val hasCaptcha = content.getElementById("challenge-error-title") != null
|
||||
val isBlocked = content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null
|
||||
if (hasCaptcha || isBlocked) {
|
||||
val request = response.request
|
||||
response.closeQuietly()
|
||||
if (isBlocked) {
|
||||
throw CloudFlareBlockedException(
|
||||
url = request.url.toString(),
|
||||
source = request.tag(MangaSource::class.java),
|
||||
)
|
||||
} else {
|
||||
throw CloudFlareProtectedException(
|
||||
url = request.url.toString(),
|
||||
source = request.tag(MangaSource::class.java),
|
||||
headers = request.headers,
|
||||
)
|
||||
}
|
||||
}
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
return when (CloudFlareHelper.checkResponseForProtection(response)) {
|
||||
CloudFlareHelper.PROTECTION_BLOCKED -> response.closeThrowing(
|
||||
CloudFlareBlockedException(
|
||||
url = request.url.toString(),
|
||||
source = request.tag(MangaSource::class.java),
|
||||
),
|
||||
)
|
||||
|
||||
CloudFlareHelper.PROTECTION_CAPTCHA -> response.closeThrowing(
|
||||
CloudFlareProtectedException(
|
||||
url = request.url.toString(),
|
||||
source = request.tag(MangaSource::class.java),
|
||||
headers = request.headers,
|
||||
),
|
||||
)
|
||||
|
||||
else -> response
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun Response.closeThrowing(error: IOException): Nothing {
|
||||
try {
|
||||
close()
|
||||
} catch (e: Exception) {
|
||||
error.addSuppressed(e)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ object CommonHeaders {
|
||||
const val CACHE_CONTROL = "Cache-Control"
|
||||
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
|
||||
const val RETRY_AFTER = "Retry-After"
|
||||
const val MANGA_SOURCE = "X-Manga-Source"
|
||||
|
||||
val CACHE_CONTROL_NO_STORE: CacheControl
|
||||
get() = CacheControl.Builder().noStore().build()
|
||||
|
||||
@@ -9,10 +9,12 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
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.util.mergeWith
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -29,15 +31,17 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
override fun intercept(chain: Chain): Response {
|
||||
val request = chain.request()
|
||||
val source = request.tag(MangaSource::class.java)
|
||||
val repository = if (source != null) {
|
||||
?: request.headers[CommonHeaders.MANGA_SOURCE]?.let { MangaSource(it) }
|
||||
val repository = if (source is MangaParserSource) {
|
||||
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
if (BuildConfig.DEBUG && source == null) {
|
||||
Log.w("Http", "Request without source tag: ${request.url}")
|
||||
}
|
||||
null
|
||||
}
|
||||
val headersBuilder = request.headers.newBuilder()
|
||||
.removeAll(CommonHeaders.MANGA_SOURCE)
|
||||
repository?.getRequestHeaders()?.let {
|
||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class DoHManager(
|
||||
).build()
|
||||
|
||||
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)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Response
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.exceptions.WrapperIOException
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
|
||||
|
||||
class GZipInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val newRequest = chain.request().newBuilder()
|
||||
newRequest.addHeader(CONTENT_ENCODING, "gzip")
|
||||
return try {
|
||||
override fun intercept(chain: Interceptor.Chain): Response = try {
|
||||
val request = chain.request()
|
||||
if (request.body is MultipartBody) {
|
||||
chain.proceed(request)
|
||||
} else {
|
||||
val newRequest = request.newBuilder()
|
||||
newRequest.addHeader(CONTENT_ENCODING, "gzip")
|
||||
chain.proceed(newRequest.build())
|
||||
} catch (e: NullPointerException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw WrapperIOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.ArraySet
|
||||
import coil.intercept.Interceptor
|
||||
import coil.network.HttpException
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.network.HttpException
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -35,14 +35,14 @@ abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
|
||||
else -> null
|
||||
}
|
||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||
return chain.proceed(request)
|
||||
return chain.proceed()
|
||||
}
|
||||
val newRequest = onInterceptImageRequest(request, url)
|
||||
return when (val result = chain.proceed(newRequest)) {
|
||||
return when (val result = chain.withRequest(newRequest).proceed()) {
|
||||
is SuccessResult -> result
|
||||
is ErrorResult -> {
|
||||
logDebug(result.throwable, newRequest.data)
|
||||
chain.proceed(request).also {
|
||||
chain.proceed().also {
|
||||
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
|
||||
blacklist.add(url.host)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import coil3.intercept.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ImageResult
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.plus
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -26,7 +26,7 @@ class RealImageProxyInterceptor @Inject constructor(
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Dimension
|
||||
import coil.size.isOriginal
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.isOriginal
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.network.imageproxy
|
||||
|
||||
import coil.request.ImageRequest
|
||||
import coil3.request.ImageRequest
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
|
||||
@@ -10,10 +10,11 @@ import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.room.InvalidationTracker
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Scale
|
||||
import coil.size.Size
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.transformations
|
||||
import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -22,21 +23,21 @@ import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||
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.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
|
||||
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.processLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -138,7 +139,7 @@ class AppShortcutManager @Inject constructor(
|
||||
ImageRequest.Builder(context)
|
||||
.data(manga.coverUrl)
|
||||
.size(iconSize)
|
||||
.source(manga.source)
|
||||
.mangaSourceExtra(manga.source)
|
||||
.scale(Scale.FILL)
|
||||
.transformations(ThumbnailTransformation())
|
||||
.build(),
|
||||
@@ -154,9 +155,10 @@ class AppShortcutManager @Inject constructor(
|
||||
.setIcon(icon)
|
||||
.setLongLived(true)
|
||||
.setIntent(
|
||||
ReaderActivity.IntentBuilder(context)
|
||||
ReaderIntent.Builder(context)
|
||||
.mangaId(manga.id)
|
||||
.build(),
|
||||
.build()
|
||||
.intent,
|
||||
)
|
||||
.build()
|
||||
}
|
||||
@@ -180,7 +182,7 @@ class AppShortcutManager @Inject constructor(
|
||||
.setLongLabel(title)
|
||||
.setIcon(icon)
|
||||
.setLongLived(true)
|
||||
.setIntent(MangaListActivity.newIntent(context, source, null))
|
||||
.setIntent(AppRouter.listIntent(context, source, null))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import coil3.network.ConnectivityChecker
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.MediatorStateFlow
|
||||
@@ -13,13 +14,17 @@ import org.koitharu.kotatsu.core.util.MediatorStateFlow
|
||||
class NetworkState(
|
||||
private val connectivityManager: ConnectivityManager,
|
||||
private val settings: AppSettings,
|
||||
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline(settings)) {
|
||||
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline(settings)), ConnectivityChecker {
|
||||
|
||||
private val callback = NetworkCallbackImpl()
|
||||
|
||||
override val value: Boolean
|
||||
get() = connectivityManager.isOnline(settings)
|
||||
|
||||
override fun isOnline(): Boolean {
|
||||
return connectivityManager.isOnline(settings)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onActive() {
|
||||
invalidate()
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.MutableLongSet
|
||||
import coil.request.CachePolicy
|
||||
import coil3.request.CachePolicy
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
@@ -14,6 +14,8 @@ import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.nav.MangaIntent
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -27,6 +29,7 @@ import javax.inject.Provider
|
||||
class MangaDataRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val resolverProvider: Provider<MangaLinkResolver>,
|
||||
private val appShortcutManagerProvider: Provider<AppShortcutManager>,
|
||||
) {
|
||||
|
||||
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
|
||||
@@ -45,8 +48,8 @@ class MangaDataRepository @Inject constructor(
|
||||
entity.copy(
|
||||
cfBrightness = colorFilter?.brightness ?: 0f,
|
||||
cfContrast = colorFilter?.contrast ?: 0f,
|
||||
cfInvert = colorFilter?.isInverted ?: false,
|
||||
cfGrayscale = colorFilter?.isGrayscale ?: false,
|
||||
cfInvert = colorFilter?.isInverted == true,
|
||||
cfGrayscale = colorFilter?.isGrayscale == true,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -70,8 +73,13 @@ class MangaDataRepository @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
suspend fun findMangaById(mangaId: Long): Manga? {
|
||||
return db.getMangaDao().find(mangaId)?.toManga()
|
||||
suspend fun findMangaById(mangaId: Long, withChapters: Boolean): Manga? {
|
||||
val chapters = if (withChapters) {
|
||||
db.getChaptersDao().findAll(mangaId).takeUnless { it.isEmpty() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return db.getMangaDao().find(mangaId)?.toManga(chapters)
|
||||
}
|
||||
|
||||
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? {
|
||||
@@ -80,7 +88,7 @@ class MangaDataRepository @Inject constructor(
|
||||
|
||||
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
||||
intent.manga != null -> intent.manga
|
||||
intent.mangaId != 0L -> findMangaById(intent.mangaId)
|
||||
intent.mangaId != 0L -> findMangaById(intent.mangaId, true)
|
||||
intent.uri != null -> resolverProvider.get().resolve(intent.uri)
|
||||
else -> null
|
||||
}
|
||||
@@ -97,10 +105,26 @@ class MangaDataRepository @Inject constructor(
|
||||
val tags = manga.tags.toEntities()
|
||||
db.getTagsDao().upsert(tags)
|
||||
db.getMangaDao().upsert(manga.toEntity(), tags)
|
||||
if (!manga.isLocal) {
|
||||
manga.chapters?.let { chapters ->
|
||||
db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateChapters(manga: Manga) {
|
||||
val chapters = manga.chapters
|
||||
if (!chapters.isNullOrEmpty() && manga.id in db.getMangaDao()) {
|
||||
db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun gcChaptersCache() {
|
||||
db.getChaptersDao().gc()
|
||||
}
|
||||
|
||||
suspend fun findTags(source: MangaSource): Set<MangaTag> {
|
||||
return db.getTagsDao().findTags(source.name).toMangaTags()
|
||||
}
|
||||
@@ -114,6 +138,14 @@ class MangaDataRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cleanupDatabase() {
|
||||
db.withTransaction {
|
||||
gcChaptersCache()
|
||||
val idsFromShortcuts = appShortcutManagerProvider.get().getMangaShortcuts()
|
||||
db.getMangaDao().cleanup(idsFromShortcuts)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
|
||||
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
|
||||
ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.net.Uri
|
||||
import coil.request.CachePolicy
|
||||
import coil3.request.CachePolicy
|
||||
import dagger.Reusable
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
@@ -15,21 +15,20 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.almostEquals
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class MangaLinkResolver @Inject constructor(
|
||||
private val repositoryFactory: MangaRepository.Factory,
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val dataRepository: MangaDataRepository,
|
||||
private val context: MangaLoaderContext,
|
||||
) {
|
||||
|
||||
suspend fun resolve(uri: Uri): Manga {
|
||||
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
|
||||
resolveAppLink(uri)
|
||||
} else {
|
||||
resolveExternalLink(uri)
|
||||
resolveExternalLink(uri.toString())
|
||||
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
|
||||
}
|
||||
|
||||
@@ -45,18 +44,11 @@ class MangaLinkResolver @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun resolveExternalLink(uri: Uri): Manga? {
|
||||
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
|
||||
private suspend fun resolveExternalLink(uri: String): Manga? {
|
||||
dataRepository.findMangaByPublicUrl(uri)?.let {
|
||||
return it
|
||||
}
|
||||
val host = uri.host ?: return null
|
||||
val repo = sourcesRepository.allMangaSources.asSequence()
|
||||
.map { source ->
|
||||
repositoryFactory.create(source) as ParserMangaRepository
|
||||
}.find { repo ->
|
||||
host in repo.domains
|
||||
} ?: return null
|
||||
return repo.findExact(uri.toString().toRelativeUrl(host), null)
|
||||
return context.newLinkResolver(uri).getManga()
|
||||
}
|
||||
|
||||
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
|
||||
@@ -85,12 +77,10 @@ class MangaLinkResolver @Inject constructor(
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
|
||||
return if (this is ParserMangaRepository) {
|
||||
getDetails(manga, CachePolicy.READ_ONLY)
|
||||
} else {
|
||||
getDetails(manga)
|
||||
}
|
||||
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga = if (this is CachingMangaRepository) {
|
||||
getDetails(manga, CachePolicy.READ_ONLY)
|
||||
} else {
|
||||
getDetails(manga)
|
||||
}
|
||||
|
||||
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
|
||||
|
||||
@@ -2,12 +2,10 @@ package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -17,6 +15,7 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.asResponseBody
|
||||
import okio.Buffer
|
||||
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
@@ -24,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeType
|
||||
import org.koitharu.kotatsu.core.util.ext.use
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
@@ -31,7 +31,6 @@ import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.map
|
||||
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -80,15 +79,14 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
|
||||
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
|
||||
return response.map { body ->
|
||||
val opts = BitmapFactory.Options()
|
||||
opts.inMutable = true
|
||||
BitmapFactory.decodeStream(body.byteStream(), null, opts)?.use { bitmap ->
|
||||
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
|
||||
Buffer().also {
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
BitmapDecoderCompat.decode(body.byteStream(), body.contentType()?.toMimeType(), isMutable = true)
|
||||
.use { bitmap ->
|
||||
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
|
||||
Buffer().also {
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
}
|
||||
}
|
||||
} ?: throw ImageDecodeException(response.request.url.toString(), response.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
@@ -17,9 +18,9 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.domain
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
|
||||
class ParserMangaRepository(
|
||||
private val parser: MangaParser,
|
||||
@@ -27,7 +28,7 @@ class ParserMangaRepository(
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache), Interceptor {
|
||||
|
||||
private val filterOptionsLazy = SuspendLazy {
|
||||
private val filterOptionsLazy = suspendLazy(Dispatchers.Default) {
|
||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getFilterOptions()
|
||||
}
|
||||
@@ -78,7 +79,9 @@ class ParserMangaRepository(
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getPageUrl(page)
|
||||
parser.getPageUrl(page).also { result ->
|
||||
check(result.isNotEmpty()) { "Page url is empty" }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import java.util.EnumSet
|
||||
|
||||
class ExternalMangaRepository(
|
||||
@@ -32,7 +32,7 @@ class ExternalMangaRepository(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private val filterOptions = SuspendLazy(contentSource::getListFilterOptions)
|
||||
private val filterOptions = suspendLazy(initializer = contentSource::getListFilterOptions)
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.core.net.toUri
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
@@ -141,7 +142,7 @@ class ExternalPluginContentSource(
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getPageUrl(url: String): String {
|
||||
val uri = "content://${source.authority}/pages/0".toUri().buildUpon()
|
||||
val uri = "content://${source.authority}/manga/pages/0".toUri().buildUpon()
|
||||
.appendQueryParameter("url", url)
|
||||
.build()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.parser.favicon
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
@@ -8,232 +7,130 @@ import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.disk.DiskCache
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.network.HttpException
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.toAndroidUri
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Closeable
|
||||
import okio.buffer
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||
import java.net.HttpURLConnection
|
||||
import org.koitharu.kotatsu.core.util.ext.fetch
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
private const val FALLBACK_SIZE = 9999 // largest icon
|
||||
import coil3.Uri as CoilUri
|
||||
|
||||
class FaviconFetcher(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val diskCache: Lazy<DiskCache?>,
|
||||
private val mangaSource: MangaSource,
|
||||
private val uri: Uri,
|
||||
private val options: Options,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) : Fetcher {
|
||||
|
||||
private val diskCacheKey
|
||||
get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}"
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val mangaSource = MangaSource(uri.schemeSpecificPart)
|
||||
|
||||
private val fileSystem
|
||||
get() = checkNotNull(diskCache.value).fileSystem
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
getCached(options)?.let { return it }
|
||||
return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
|
||||
is ParserMangaRepository -> fetchParserFavicon(repo)
|
||||
is ExternalMangaRepository -> fetchPluginIcon(repo)
|
||||
is EmptyMangaRepository -> DrawableResult(
|
||||
drawable = ColorDrawable(Color.WHITE),
|
||||
is EmptyMangaRepository -> ImageFetchResult(
|
||||
image = ColorDrawable(Color.WHITE).asImage(),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("")
|
||||
is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options)
|
||||
|
||||
else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult {
|
||||
private suspend fun fetchParserFavicon(repository: ParserMangaRepository): FetchResult {
|
||||
val sizePx = maxOf(
|
||||
options.size.width.pxOrElse { FALLBACK_SIZE },
|
||||
options.size.height.pxOrElse { FALLBACK_SIZE },
|
||||
)
|
||||
var favicons = repo.getFavicons()
|
||||
var favicons = repository.getFavicons()
|
||||
var lastError: Exception? = null
|
||||
while (favicons.isNotEmpty()) {
|
||||
coroutineContext.ensureActive()
|
||||
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
|
||||
val response = try {
|
||||
loadIcon(icon.url, mangaSource)
|
||||
try {
|
||||
val result = imageLoader.fetch(icon.url, options)
|
||||
if (result != null) {
|
||||
return result
|
||||
} else {
|
||||
favicons -= icon
|
||||
}
|
||||
} catch (e: CloudFlareProtectedException) {
|
||||
throw e
|
||||
} catch (e: HttpException) {
|
||||
} catch (e: IOException) {
|
||||
lastError = e
|
||||
favicons -= icon
|
||||
continue
|
||||
}
|
||||
val responseBody = response.requireBody()
|
||||
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
|
||||
response.closeQuietly()
|
||||
} ?: responseBody.toImageSource(response)
|
||||
return SourceResult(
|
||||
source = source,
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
|
||||
dataSource = response.toDataSource(),
|
||||
)
|
||||
}
|
||||
throwNSEE(lastError)
|
||||
}
|
||||
|
||||
private suspend fun loadIcon(url: String, source: MangaSource): Response {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.tag(MangaSource::class.java, source)
|
||||
request.tag(MangaSource::class.java, source)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
|
||||
val response = okHttpClient.newCall(request.build()).await()
|
||||
if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) {
|
||||
response.closeQuietly()
|
||||
throw HttpException(response)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult {
|
||||
val source = repository.source
|
||||
val pm = options.context.packageManager
|
||||
val icon = runInterruptible(Dispatchers.IO) {
|
||||
val icon = runInterruptible {
|
||||
val provider = pm.resolveContentProvider(source.authority, 0)
|
||||
provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName)
|
||||
}
|
||||
return DrawableResult(
|
||||
drawable = icon.nonAdaptive(),
|
||||
return ImageFetchResult(
|
||||
image = icon.nonAdaptive().asImage(),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCached(options: Options): SourceResult? {
|
||||
if (!options.diskCachePolicy.readEnabled) {
|
||||
return null
|
||||
}
|
||||
val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null
|
||||
return SourceResult(
|
||||
source = snapshot.toImageSource(),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? {
|
||||
if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) {
|
||||
return null
|
||||
}
|
||||
val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null
|
||||
try {
|
||||
fileSystem.write(editor.data) {
|
||||
writeAllCancellable(body.source())
|
||||
}
|
||||
return editor.commitAndOpenSnapshot()
|
||||
} catch (e: Throwable) {
|
||||
try {
|
||||
editor.abort()
|
||||
} catch (abortingError: Throwable) {
|
||||
e.addSuppressed(abortingError)
|
||||
}
|
||||
body.closeQuietly()
|
||||
throw e
|
||||
} finally {
|
||||
body.closeQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun DiskCache.Snapshot.toImageSource(): ImageSource {
|
||||
return ImageSource(data, fileSystem, diskCacheKey, this)
|
||||
}
|
||||
|
||||
private fun ResponseBody.toImageSource(response: Closeable): ImageSource {
|
||||
return ImageSource(
|
||||
source().withExtraCloseable(response).buffer(),
|
||||
options.context,
|
||||
FaviconMetadata(mangaSource),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Response.toDataSource(): DataSource {
|
||||
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
|
||||
}
|
||||
|
||||
private fun Size.toCacheKey() = buildString {
|
||||
append(width.toString())
|
||||
append('x')
|
||||
append(height.toString())
|
||||
}
|
||||
|
||||
private fun throwNSEE(lastError: Exception?): Nothing {
|
||||
if (lastError != null) {
|
||||
throw lastError
|
||||
} else {
|
||||
throw NoSuchElementException("No favicons found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Drawable.nonAdaptive() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
|
||||
LayerDrawable(arrayOf(background, foreground))
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
class Factory(
|
||||
context: Context,
|
||||
okHttpClientLazy: Lazy<OkHttpClient>,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) : Fetcher.Factory<Uri> {
|
||||
) : Fetcher.Factory<CoilUri> {
|
||||
|
||||
private val okHttpClient by okHttpClientLazy
|
||||
private val diskCache = lazy {
|
||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||
DiskCache.Builder()
|
||||
.directory(rootDir.resolve(CacheDir.FAVICONS.dir))
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
|
||||
return if (data.scheme == URI_SCHEME_FAVICON) {
|
||||
val mangaSource = MangaSource(data.schemeSpecificPart)
|
||||
FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
override fun create(
|
||||
data: CoilUri,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
|
||||
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
class FaviconMetadata(val source: MangaSource) : ImageSource.Metadata()
|
||||
private companion object {
|
||||
|
||||
const val FALLBACK_SIZE = 9999 // largest icon
|
||||
|
||||
private fun throwNSEE(lastError: Exception?): Nothing {
|
||||
if (lastError != null) {
|
||||
throw lastError
|
||||
} else {
|
||||
throw NoSuchElementException("No favicons found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Drawable.nonAdaptive() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) {
|
||||
LayerDrawable(arrayOf(background, foreground))
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
@@ -29,10 +30,12 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
import java.io.File
|
||||
import java.net.Proxy
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -119,6 +122,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
||||
|
||||
val readerScreenOrientation: Int
|
||||
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
|
||||
val isReaderVolumeButtonsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false)
|
||||
|
||||
@@ -134,10 +141,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReaderOptimizationEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
|
||||
|
||||
var isTrafficWarningEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
||||
|
||||
val isOfflineCheckDisabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
|
||||
|
||||
@@ -296,6 +299,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getInt(KEY_SOURCES_VERSION, 0)
|
||||
set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) }
|
||||
|
||||
var isAllSourcesEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SOURCES_ENABLED_ALL, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_SOURCES_ENABLED_ALL, value) }
|
||||
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
@@ -328,8 +335,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val isDownloadsWiFiOnly: Boolean
|
||||
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
|
||||
var allowDownloadOnMeteredNetwork: TriStateOption
|
||||
get() = prefs.getEnumValue(KEY_DOWNLOADS_METERED_NETWORK, TriStateOption.ASK)
|
||||
set(value) = prefs.edit { putEnumValue(KEY_DOWNLOADS_METERED_NETWORK, value) }
|
||||
|
||||
val preferredDownloadFormat: DownloadFormat
|
||||
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
|
||||
@@ -359,9 +367,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReaderBarEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_BAR, true)
|
||||
|
||||
val isReaderSliderEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_SLIDER, true)
|
||||
|
||||
val isReaderKeepScreenOn: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
|
||||
|
||||
@@ -409,10 +414,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0
|
||||
|
||||
val proxyLogin: String?
|
||||
get() = prefs.getString(KEY_PROXY_LOGIN, null)?.takeUnless { it.isEmpty() }
|
||||
get() = prefs.getString(KEY_PROXY_LOGIN, null)?.nullIfEmpty()
|
||||
|
||||
val proxyPassword: String?
|
||||
get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.takeUnless { it.isEmpty() }
|
||||
get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.nullIfEmpty()
|
||||
|
||||
var localListOrder: SortOrder
|
||||
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
|
||||
@@ -471,10 +476,26 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val periodicalBackupFrequency: Long
|
||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
|
||||
|
||||
var periodicalBackupOutput: Uri?
|
||||
val periodicalBackupFrequencyMillis: Long
|
||||
get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
|
||||
|
||||
val periodicalBackupMaxCount: Int
|
||||
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
|
||||
prefs.getInt(KEY_BACKUP_PERIODICAL_COUNT, 10)
|
||||
} else {
|
||||
Int.MAX_VALUE
|
||||
}
|
||||
|
||||
var periodicalBackupDirectory: Uri?
|
||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
|
||||
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
|
||||
|
||||
val isBackupTelegramUploadEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_BACKUP_TG_ENABLED, false)
|
||||
|
||||
val backupTelegramChatId: String?
|
||||
get() = prefs.getString(KEY_BACKUP_TG_CHAT, null)?.nullIfEmpty()
|
||||
|
||||
val isReadingTimeEstimationEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READING_TIME, true)
|
||||
|
||||
@@ -573,7 +594,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_THEME = "theme"
|
||||
const val KEY_COLOR_THEME = "color_theme"
|
||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
||||
const val KEY_OFFLINE_DISABLED = "no_offline"
|
||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
|
||||
@@ -592,6 +612,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||
const val KEY_READER_FULLSCREEN = "reader_fullscreen"
|
||||
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
|
||||
const val KEY_READER_ORIENTATION = "reader_orientation"
|
||||
const val KEY_TRACKER_ENABLED = "tracker_enabled"
|
||||
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
|
||||
const val KEY_TRACKER_FREQUENCY = "tracker_freq"
|
||||
@@ -619,6 +640,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_RESTORE = "restore"
|
||||
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
|
||||
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
|
||||
const val KEY_BACKUP_PERIODICAL_TRIM = "backup_periodic_trim"
|
||||
const val KEY_BACKUP_PERIODICAL_COUNT = "backup_periodic_count"
|
||||
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
|
||||
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
|
||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||
@@ -639,7 +662,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_ANILIST = "anilist"
|
||||
const val KEY_MAL = "mal"
|
||||
const val KEY_KITSU = "kitsu"
|
||||
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
|
||||
const val KEY_DOWNLOADS_METERED_NETWORK = "downloads_metered_network"
|
||||
const val KEY_DOWNLOADS_FORMAT = "downloads_format"
|
||||
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
|
||||
const val KEY_DOH = "doh"
|
||||
@@ -648,7 +671,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_SYNC = "sync"
|
||||
const val KEY_SYNC_SETTINGS = "sync_settings"
|
||||
const val KEY_READER_BAR = "reader_bar"
|
||||
const val KEY_READER_SLIDER = "reader_slider"
|
||||
const val KEY_READER_BACKGROUND = "reader_background"
|
||||
const val KEY_READER_SCREEN_ON = "reader_screen_on"
|
||||
const val KEY_SHORTCUTS = "dynamic_shortcuts"
|
||||
@@ -699,7 +721,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_FEED_HEADER = "feed_header"
|
||||
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||
const val KEY_SOURCES_VERSION = "sources_version"
|
||||
const val KEY_SOURCES_ENABLED_ALL = "sources_enabled_all"
|
||||
const val KEY_QUICK_FILTER = "quick_filter"
|
||||
const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled"
|
||||
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
|
||||
|
||||
// keys for non-persistent preferences
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
@@ -710,7 +735,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_LINK_TELEGRAM = "about_telegram"
|
||||
const val KEY_LINK_GITHUB = "about_github"
|
||||
const val KEY_LINK_MANUAL = "about_help"
|
||||
const val PROXY_TEST = "proxy_test"
|
||||
const val KEY_PROXY_TEST = "proxy_test"
|
||||
const val KEY_OPEN_BROWSER = "open_browser"
|
||||
const val KEY_HANDLE_LINKS = "handle_links"
|
||||
const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open"
|
||||
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
|
||||
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||
|
||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||
@@ -38,7 +39,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
|
||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.takeUnless(String::isEmpty)
|
||||
is ConfigKey.PreferredImageServer -> prefs.getString(key.key, key.defaultValue)?.nullIfEmpty()
|
||||
} as T
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
enum class TriStateOption {
|
||||
|
||||
ENABLED, ASK, DISABLED;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||
@@ -112,9 +113,13 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
ActivityCompat.recreate(this)
|
||||
return true
|
||||
if (BuildConfig.DEBUG) {
|
||||
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
ActivityCompat.recreate(this)
|
||||
return true
|
||||
} else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
|
||||
throw RuntimeException("Test crash")
|
||||
}
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
@@ -155,7 +160,7 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
override fun isNsfwContent(): Flow<Boolean> = flowOf(false)
|
||||
|
||||
private fun putDataToExtras(intent: Intent?) {
|
||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||
intent?.putExtra(AppRouter.KEY_DATA, intent.data)
|
||||
}
|
||||
|
||||
protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean {
|
||||
@@ -174,9 +179,4 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
protected fun hasViewBinding() = ::viewBinding.isInitialized
|
||||
|
||||
companion object {
|
||||
|
||||
const val EXTRA_DATA = "data"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.get
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
@@ -67,6 +68,10 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setTitle(if (titleId != 0) getString(titleId) else null)
|
||||
arguments?.getString(SettingsActivity.ARG_PREF_KEY)?.let {
|
||||
focusPreference(it)
|
||||
arguments?.remove(SettingsActivity.ARG_PREF_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
@@ -80,11 +85,30 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||
}
|
||||
|
||||
protected fun startActivitySafe(intent: Intent): Boolean = try {
|
||||
startActivity(intent)
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
false
|
||||
private fun focusPreference(key: String) {
|
||||
val pref = findPreference<Preference>(key)
|
||||
if (pref == null) {
|
||||
scrollToPreference(key)
|
||||
return
|
||||
}
|
||||
scrollToPreference(pref)
|
||||
val prefIndex = preferenceScreen.indexOf(key)
|
||||
val view = if (prefIndex >= 0) {
|
||||
listView.findViewHolderForAdapterPosition(prefIndex)?.itemView ?: return
|
||||
} else {
|
||||
return
|
||||
}
|
||||
view.context.getThemeDrawable(materialR.attr.colorTertiaryContainer)?.let {
|
||||
view.background = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun PreferenceScreen.indexOf(key: String): Int {
|
||||
for (i in 0 until preferenceCount) {
|
||||
if (get(i).key == key) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -9,11 +10,10 @@ import android.os.PatternMatcher
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -21,60 +21,111 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
abstract class CoroutineIntentService : BaseService() {
|
||||
|
||||
private val mutex = Mutex()
|
||||
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
val job = launchCoroutine(intent, startId)
|
||||
val receiver = CancelReceiver(job)
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
receiver,
|
||||
createIntentFilter(this, startId),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
job.invokeOnCompletion { unregisterReceiver(receiver) }
|
||||
launchCoroutine(intent, startId)
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) {
|
||||
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
|
||||
val intentJobContext = IntentJobContextImpl(startId, coroutineContext)
|
||||
mutex.withLock {
|
||||
try {
|
||||
if (intent != null) {
|
||||
withContext(dispatcher) {
|
||||
processIntent(startId, intent)
|
||||
withContext(Dispatchers.Default) {
|
||||
intentJobContext.processIntent(intent)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
onError(startId, e)
|
||||
intentJobContext.onError(e)
|
||||
} finally {
|
||||
stopSelf(startId)
|
||||
intentJobContext.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
protected abstract suspend fun processIntent(startId: Int, intent: Intent)
|
||||
protected abstract suspend fun IntentJobContext.processIntent(intent: Intent)
|
||||
|
||||
@AnyThread
|
||||
protected abstract fun onError(startId: Int, error: Throwable)
|
||||
protected abstract fun IntentJobContext.onError(error: Throwable)
|
||||
|
||||
protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
createCancelIntent(this, startId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
)
|
||||
interface IntentJobContext {
|
||||
|
||||
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
|
||||
throwable.printStackTraceDebug()
|
||||
onError(startId, throwable)
|
||||
val startId: Int
|
||||
|
||||
fun getCancelIntent(): PendingIntent?
|
||||
|
||||
fun setForeground(id: Int, notification: Notification, serviceType: Int)
|
||||
}
|
||||
|
||||
protected inner class IntentJobContextImpl(
|
||||
override val startId: Int,
|
||||
private val coroutineContext: CoroutineContext,
|
||||
) : IntentJobContext {
|
||||
|
||||
private var cancelReceiver: CancelReceiver? = null
|
||||
private var isStopped = false
|
||||
private var isForeground = false
|
||||
|
||||
override fun getCancelIntent(): PendingIntent? {
|
||||
ensureHasCancelReceiver()
|
||||
return PendingIntentCompat.getBroadcast(
|
||||
applicationContext,
|
||||
0,
|
||||
createCancelIntent(this@CoroutineIntentService, startId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
override fun setForeground(id: Int, notification: Notification, serviceType: Int) {
|
||||
ServiceCompat.startForeground(this@CoroutineIntentService, id, notification, serviceType)
|
||||
isForeground = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(this) {
|
||||
cancelReceiver?.let {
|
||||
try {
|
||||
unregisterReceiver(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
isStopped = true
|
||||
}
|
||||
if (isForeground) {
|
||||
ServiceCompat.stopForeground(this@CoroutineIntentService, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
stopSelf(startId)
|
||||
}
|
||||
|
||||
private fun ensureHasCancelReceiver() {
|
||||
if (cancelReceiver == null && !isStopped) {
|
||||
synchronized(this) {
|
||||
if (cancelReceiver == null && !isStopped) {
|
||||
val job = coroutineContext[Job] ?: return
|
||||
CancelReceiver(job).let { receiver ->
|
||||
ContextCompat.registerReceiver(
|
||||
applicationContext,
|
||||
receiver,
|
||||
createIntentFilter(this@CoroutineIntentService, startId),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
cancelReceiver = receiver
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CancelReceiver(
|
||||
|
||||
@@ -6,12 +6,13 @@ import android.view.LayoutInflater
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding
|
||||
|
||||
class TwoButtonsAlertDialog private constructor(
|
||||
class BigButtonsAlertDialog private constructor(
|
||||
private val delegate: AlertDialog
|
||||
) : DialogInterface by delegate {
|
||||
|
||||
@@ -51,14 +52,44 @@ class TwoButtonsAlertDialog private constructor(
|
||||
@StringRes textId: Int,
|
||||
listener: DialogInterface.OnClickListener? = null
|
||||
): Builder {
|
||||
initButton(binding.button2, DialogInterface.BUTTON_NEGATIVE, textId, listener)
|
||||
initButton(binding.button3, DialogInterface.BUTTON_NEGATIVE, textId, listener)
|
||||
return this
|
||||
}
|
||||
|
||||
fun create(): TwoButtonsAlertDialog {
|
||||
fun setNeutralButton(
|
||||
@StringRes textId: Int,
|
||||
listener: DialogInterface.OnClickListener? = null
|
||||
): Builder {
|
||||
initButton(binding.button2, DialogInterface.BUTTON_NEUTRAL, textId, listener)
|
||||
return this
|
||||
}
|
||||
|
||||
fun create(): BigButtonsAlertDialog {
|
||||
with(binding) {
|
||||
button1.adjustCorners(isFirst = true, isLast = button2.isGone && button3.isGone)
|
||||
button2.adjustCorners(isFirst = button1.isGone, isLast = button3.isGone)
|
||||
button3.adjustCorners(isFirst = button1.isGone && button2.isGone, isLast = true)
|
||||
}
|
||||
|
||||
val dialog = delegate.create()
|
||||
binding.root.tag = dialog
|
||||
return TwoButtonsAlertDialog(dialog)
|
||||
return BigButtonsAlertDialog(dialog)
|
||||
}
|
||||
|
||||
private fun MaterialButton.adjustCorners(isFirst: Boolean, isLast: Boolean) {
|
||||
if (!isVisible) {
|
||||
return
|
||||
}
|
||||
shapeAppearanceModel = shapeAppearanceModel.toBuilder().apply {
|
||||
if (!isFirst) {
|
||||
setTopLeftCornerSize(0f)
|
||||
setTopRightCornerSize(0f)
|
||||
}
|
||||
if (!isLast) {
|
||||
setBottomLeftCornerSize(0f)
|
||||
setBottomRightCornerSize(0f)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun initButton(
|
||||
@@ -1,25 +1,58 @@
|
||||
package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.core.net.ConnectivityManagerCompat
|
||||
import dagger.Lazy
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import javax.inject.Inject
|
||||
|
||||
object CommonAlertDialogs {
|
||||
class CommonAlertDialogs @Inject constructor(
|
||||
private val settings: Lazy<AppSettings>,
|
||||
) {
|
||||
|
||||
fun showDownloadConfirmation(
|
||||
fun askForDownloadOverMeteredNetwork(
|
||||
@UiContext context: Context,
|
||||
onConfirmed: (startPaused: Boolean) -> Unit,
|
||||
) = buildAlertDialog(context, isCentered = true) {
|
||||
var startPaused = false
|
||||
setTitle(R.string.save_manga)
|
||||
setIcon(R.drawable.ic_download)
|
||||
setMessage(R.string.save_manga_confirm)
|
||||
setCheckbox(R.string.start_download, true) { _, isChecked ->
|
||||
startPaused = !isChecked
|
||||
onConfirmed: (allow: Boolean) -> Unit
|
||||
) {
|
||||
when (settings.get().allowDownloadOnMeteredNetwork) {
|
||||
TriStateOption.ENABLED -> onConfirmed(true)
|
||||
TriStateOption.DISABLED -> onConfirmed(false)
|
||||
TriStateOption.ASK -> {
|
||||
if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
|
||||
onConfirmed(true)
|
||||
return
|
||||
}
|
||||
val listener = DialogInterface.OnClickListener { _, which ->
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEUTRAL -> {
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEGATIVE -> {
|
||||
settings.get().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()
|
||||
}
|
||||
}
|
||||
setPositiveButton(R.string.save) { _, _ ->
|
||||
onConfirmed(startPaused)
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ import androidx.core.text.HtmlCompat
|
||||
import androidx.core.text.htmlEncode
|
||||
import androidx.core.text.method.LinkMovementMethodCompat
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.isReportable
|
||||
import org.koitharu.kotatsu.core.util.ext.report
|
||||
import org.koitharu.kotatsu.core.util.ext.requireSerializable
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding
|
||||
|
||||
class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
@@ -27,7 +27,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val args = requireArguments()
|
||||
exception = args.requireSerializable(ARG_ERROR)
|
||||
exception = args.requireSerializable(AppRouter.KEY_ERROR)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogErrorDetailsBinding {
|
||||
@@ -41,7 +41,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
text = context.getString(
|
||||
R.string.manga_error_description_pattern,
|
||||
exception.message?.htmlEncode().orEmpty(),
|
||||
arguments?.getString(ARG_URL),
|
||||
arguments?.getString(AppRouter.KEY_URL) ?: exception.getCauseUrl(),
|
||||
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
if (exception.isReportable()) {
|
||||
builder.setPositiveButton(R.string.report) { _, _ ->
|
||||
dismiss()
|
||||
exception.report()
|
||||
exception.report(silent = true)
|
||||
}
|
||||
}
|
||||
return builder
|
||||
@@ -71,16 +71,4 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "ErrorDetailsDialog"
|
||||
private const val ARG_ERROR = "error"
|
||||
private const val ARG_URL = "url"
|
||||
|
||||
fun show(fm: FragmentManager, error: Throwable, url: String?) = ErrorDetailsDialog().withArgs(2) {
|
||||
putSerializable(ARG_ERROR, error)
|
||||
putString(ARG_URL, url)
|
||||
}.show(fm, TAG)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,16 @@ import android.graphics.Canvas
|
||||
import android.graphics.drawable.Animatable
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import coil3.Image
|
||||
import coil3.asImage
|
||||
import coil3.getExtra
|
||||
import coil3.request.ImageRequest
|
||||
import com.google.android.material.animation.ArgbEvaluatorCompat
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
||||
import kotlin.math.abs
|
||||
|
||||
class AnimatedFaviconDrawable(
|
||||
@@ -23,12 +28,12 @@ class AnimatedFaviconDrawable(
|
||||
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
|
||||
private val timeAnimator = TimeAnimator()
|
||||
|
||||
private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
|
||||
private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground)
|
||||
private var colorHigh = MaterialColors.harmonize(colorForeground, currentBackgroundColor)
|
||||
private var colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor)
|
||||
|
||||
init {
|
||||
timeAnimator.setTimeListener(this)
|
||||
updateColor()
|
||||
onStateChange(state)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
@@ -39,9 +44,11 @@ class AnimatedFaviconDrawable(
|
||||
super.draw(canvas)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) = Unit
|
||||
|
||||
override fun getAlpha(): Int = 255
|
||||
// override fun setAlpha(alpha: Int) = Unit
|
||||
//
|
||||
// override fun getAlpha(): Int = 255
|
||||
//
|
||||
// override fun isOpaque(): Boolean = false
|
||||
|
||||
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
|
||||
callback?.also {
|
||||
@@ -60,13 +67,33 @@ class AnimatedFaviconDrawable(
|
||||
|
||||
override fun isRunning(): Boolean = timeAnimator.isStarted
|
||||
|
||||
override fun onStateChange(state: IntArray): Boolean {
|
||||
val res = super.onStateChange(state)
|
||||
colorHigh = MaterialColors.harmonize(currentForegroundColor, currentBackgroundColor)
|
||||
colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor)
|
||||
updateColor()
|
||||
return res
|
||||
}
|
||||
|
||||
private fun updateColor() {
|
||||
if (period <= 0f) {
|
||||
return
|
||||
}
|
||||
val ph = period / 2
|
||||
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
|
||||
colorForeground = ArgbEvaluatorCompat.getInstance()
|
||||
currentForegroundColor = ArgbEvaluatorCompat.getInstance()
|
||||
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
@StyleRes private val styleResId: Int,
|
||||
) : ((ImageRequest) -> Image?) {
|
||||
|
||||
override fun invoke(request: ImageRequest): Image? {
|
||||
val source = request.getExtra(mangaSourceKey) ?: return null
|
||||
val context = request.context
|
||||
val title = source.getTitle(context)
|
||||
return AnimatedFaviconDrawable(context, styleResId, title).asImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.graphics.ColorFilter
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import com.google.android.material.animation.ArgbEvaluatorCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -23,6 +24,7 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
|
||||
private val interpolator = FastOutSlowInInterpolator()
|
||||
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
|
||||
private val timeAnimator = TimeAnimator()
|
||||
private var currentAlpha: Int = 255
|
||||
|
||||
init {
|
||||
timeAnimator.setTimeListener(this)
|
||||
@@ -38,16 +40,17 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
// this.alpha = alpha FIXME coil's crossfade
|
||||
currentAlpha = alpha
|
||||
updateColor()
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||
|
||||
@Suppress("DeprecatedCallableAddReplaceWith")
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun getOpacity(): Int = PixelFormat.OPAQUE
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
|
||||
override fun getAlpha(): Int = 255
|
||||
override fun getAlpha(): Int = currentAlpha
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||
|
||||
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
|
||||
callback?.also {
|
||||
@@ -72,7 +75,10 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti
|
||||
}
|
||||
val ph = period / 2
|
||||
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
|
||||
currentColor = ArgbEvaluatorCompat.getInstance()
|
||||
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
|
||||
currentColor = ColorUtils.setAlphaComponent(
|
||||
ArgbEvaluatorCompat.getInstance()
|
||||
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh),
|
||||
currentAlpha
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.ui.image
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import coil.target.GenericViewTarget
|
||||
import coil3.target.GenericViewTarget
|
||||
import com.google.android.material.chip.Chip
|
||||
|
||||
class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user