Compare commits
410 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc1d704f6f | ||
|
|
c2c3b0f757 | ||
|
|
8d519dd80f | ||
|
|
3b5a9cd2b4 | ||
|
|
95f4d39893 | ||
|
|
f3f269c7fa | ||
|
|
40f262b0ef | ||
|
|
0f68be9663 | ||
|
|
0b8afe9c40 | ||
|
|
754ccc4197 | ||
|
|
ef691b1aed | ||
|
|
1bd916371a | ||
|
|
cd40dab8a4 | ||
|
|
d2ed8a1ace | ||
|
|
024e3c11ee | ||
|
|
23ba302df8 | ||
|
|
34e54e43e0 | ||
|
|
07a8de6225 | ||
|
|
a3df6f799c | ||
|
|
d5722790ef | ||
|
|
8bf540abbe | ||
|
|
5241fa0d13 | ||
|
|
87e0c931a2 | ||
|
|
a51412801a | ||
|
|
a6c188d647 | ||
|
|
831632cb8f | ||
|
|
ad59bf50f4 | ||
|
|
6fe6c05327 | ||
|
|
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 | ||
|
|
644f0af262 | ||
|
|
a1e5d78877 | ||
|
|
635839065d | ||
|
|
bb6f7b1e9f | ||
|
|
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 | ||
|
|
b46c00f2d0 | ||
|
|
9358617a3a | ||
|
|
ba9f31835f | ||
|
|
357308bfbb | ||
|
|
cab56209c1 | ||
|
|
e9cd32c870 | ||
|
|
357517ceac | ||
|
|
a57fcce72b | ||
|
|
2e2a818c05 | ||
|
|
b6f618101f | ||
|
|
0ce368751a | ||
|
|
1d28538893 | ||
|
|
4ad2f3f608 | ||
|
|
5301cc7f97 | ||
|
|
1290db4a7c | ||
|
|
1f1309d934 | ||
|
|
350f1521a6 | ||
|
|
cebce20bed | ||
|
|
e5b6947586 | ||
|
|
ac96c49b60 | ||
|
|
a4345a40bf | ||
|
|
f518acb8ee | ||
|
|
b39a51d497 | ||
|
|
8819d8b1ee | ||
|
|
05a502b89a | ||
|
|
c320e3c26a | ||
|
|
938849c31e | ||
|
|
95c243daa1 | ||
|
|
6ce6a02b56 | ||
|
|
e92e9fb393 | ||
|
|
f4186a2787 | ||
|
|
8b93b699d3 | ||
|
|
7e13482ba5 | ||
|
|
04700a22c8 | ||
|
|
549d08cc06 | ||
|
|
0fccaf3fbc | ||
|
|
c7e0a47bee | ||
|
|
d527b6e390 | ||
|
|
12b2af6b93 | ||
|
|
63f4fab40f | ||
|
|
9a444cf965 | ||
|
|
b8be2f7158 | ||
|
|
9e2074040f | ||
|
|
020c151e31 | ||
|
|
52eb33a992 | ||
|
|
907b8fd0ec | ||
|
|
e35b2088a1 | ||
|
|
fbb4efb3df | ||
|
|
7ff47a322e | ||
|
|
fda1af5500 | ||
|
|
d88847d137 | ||
|
|
063527b240 | ||
|
|
b0470110a8 | ||
|
|
5a2a31d1c8 | ||
|
|
3b009d7c55 | ||
|
|
f7e937f2b8 | ||
|
|
16e23cc1cf | ||
|
|
d12528d80f | ||
|
|
9f04c7b148 | ||
|
|
7a3942f100 | ||
|
|
8e46f64f2a | ||
|
|
44c50fca2d | ||
|
|
55b4d14a93 | ||
|
|
743693299f | ||
|
|
7950a685a6 | ||
|
|
97cfcb5c01 | ||
|
|
b2dfcefee8 | ||
|
|
ee1ade40c3 | ||
|
|
3690e15cff | ||
|
|
a955dfbe50 | ||
|
|
5e9daa1206 | ||
|
|
a3c2956a4d | ||
|
|
10ecd92715 | ||
|
|
37d2d986ef | ||
|
|
0aadd6ebe2 | ||
|
|
c23ec9a4b8 | ||
|
|
22a37923f9 | ||
|
|
3fc506b438 | ||
|
|
e98dbd5069 | ||
|
|
2a469b27c5 | ||
|
|
0f3ef4559f | ||
|
|
a87ef0a0a6 | ||
|
|
a7a0a7f0db | ||
|
|
bc4622d610 | ||
|
|
8365603bf1 | ||
|
|
b1eabdba79 | ||
|
|
169e31e9ba | ||
|
|
66644d55a4 | ||
|
|
98314960cf | ||
|
|
b73e44874d | ||
|
|
6f45a44070 | ||
|
|
d9d11d685e | ||
|
|
5359267b5a | ||
|
|
a6662ab501 | ||
|
|
699a619c27 | ||
|
|
85ccbbf719 | ||
|
|
a396b33f3d | ||
|
|
6076f775c3 | ||
|
|
379fa88b4e | ||
|
|
9b24c507c5 | ||
|
|
98bd79f0be | ||
|
|
f09e28e782 | ||
|
|
b601b07586 | ||
|
|
73cea59691 | ||
|
|
e2993d47b6 | ||
|
|
2cd67e7cf8 | ||
|
|
c51da5a9d5 | ||
|
|
bcfce29610 | ||
|
|
a87d18fae3 | ||
|
|
bbd421445c | ||
|
|
f4e3d797dc | ||
|
|
bd65cbb8b8 | ||
|
|
7d1f81607a | ||
|
|
3b6cd0ea7f | ||
|
|
aff70d8519 | ||
|
|
8a74faa4f0 | ||
|
|
c1ac207809 | ||
|
|
e34e745c84 | ||
|
|
50dd119ab5 | ||
|
|
d0ef177d56 | ||
|
|
9b9c2e49b9 | ||
|
|
afeb307453 | ||
|
|
7568b1aedc | ||
|
|
742d8cee00 | ||
|
|
d52bef28ff | ||
|
|
b2f48421c7 | ||
|
|
22643bf9cc | ||
|
|
861ca63ea9 | ||
|
|
6e34356b6f | ||
|
|
a9b3025724 | ||
|
|
0cc019ef19 | ||
|
|
eb49b31aeb | ||
|
|
5ebbfd1c00 | ||
|
|
0df67b86f8 | ||
|
|
2df567372e | ||
|
|
0fb3c69e10 | ||
|
|
44900dbcbe | ||
|
|
89cd295f28 | ||
|
|
d06811d94d | ||
|
|
ced22ebb0a | ||
|
|
3c2ad26f1d | ||
|
|
9535e35ba7 | ||
|
|
298e87dce2 | ||
|
|
165ce61ded | ||
|
|
c70e3547d1 | ||
|
|
05b5953f35 | ||
|
|
9cba6e694a | ||
|
|
b2c73ec9d8 | ||
|
|
cd7620673b | ||
|
|
09bfb2b0f4 | ||
|
|
6dde7e9535 | ||
|
|
98e24072ce | ||
|
|
fdc67f8f0e | ||
|
|
f8acabcc86 | ||
|
|
5a0771b751 | ||
|
|
6a231f76e1 | ||
|
|
d203edbdae | ||
|
|
91a5aa8d4c | ||
|
|
40076dea36 | ||
|
|
2ab7228727 | ||
|
|
52e500a5fb | ||
|
|
44734867cc | ||
|
|
6565d05274 | ||
|
|
204758cbbb | ||
|
|
a60e7e13ca | ||
|
|
3596109249 | ||
|
|
3c703d9771 | ||
|
|
1e87dc4c52 | ||
|
|
ae61d50a6c | ||
|
|
63fb40dd65 | ||
|
|
afe2248bb8 | ||
|
|
b3b82ace3f | ||
|
|
903fef6791 | ||
|
|
542ad29cd9 | ||
|
|
d588e8d941 | ||
|
|
6b786084cf | ||
|
|
85da41be9a | ||
|
|
6e8a1cd6af | ||
|
|
0f28d5de11 | ||
|
|
0d39909d89 | ||
|
|
e4282a8e9d | ||
|
|
05a64308ac | ||
|
|
7b01bafd53 | ||
|
|
b521460335 | ||
|
|
249c8377bd | ||
|
|
58cdc9f29a | ||
|
|
065beb72e1 | ||
|
|
c00614f17d | ||
|
|
d99bc08e49 | ||
|
|
9e49b28ac3 | ||
|
|
d06b396aec | ||
|
|
65abef1282 | ||
|
|
b66d3ee8d4 | ||
|
|
597abdb20c | ||
|
|
174fa800be | ||
|
|
28670bc7fb | ||
|
|
a61e406c91 | ||
|
|
20f357cb12 | ||
|
|
5ba6b81fac | ||
|
|
e34bcd47d5 | ||
|
|
62ed8705e8 | ||
|
|
de18324798 | ||
|
|
a7a943c8dc | ||
|
|
6e975b9d66 | ||
|
|
9e53dc3d5f | ||
|
|
809e7d8701 | ||
|
|
0015c5704a | ||
|
|
a7ff1610eb | ||
|
|
22c402fc5e | ||
|
|
f3c19f9c02 | ||
|
|
33b4b9fbcb | ||
|
|
00396f2e1b | ||
|
|
8b71f99666 | ||
|
|
d00822a6c3 | ||
|
|
6e92d46a63 | ||
|
|
66ed926ea8 | ||
|
|
b7741ce2af | ||
|
|
1a17324d26 | ||
|
|
4044936481 | ||
|
|
1efe86421a | ||
|
|
34dd080f6c | ||
|
|
f4838afab0 | ||
|
|
b207eebe56 | ||
|
|
4f454ab438 | ||
|
|
1ecf416113 | ||
|
|
94670a03ff | ||
|
|
e92f165677 | ||
|
|
4a03137a25 | ||
|
|
7e6e1fb6de | ||
|
|
f477797823 | ||
|
|
125b6740a6 | ||
|
|
1618a11955 | ||
|
|
966d6e2383 | ||
|
|
2f33a135fc | ||
|
|
207ea492d5 | ||
|
|
250d5432a0 | ||
|
|
9768758ecc | ||
|
|
20852dbd12 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,3 +26,4 @@
|
||||
.cxx
|
||||
/.idea/deviceManager.xml
|
||||
/.kotlin/
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
|
||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -4,6 +4,7 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
|
||||
18
README.md
18
README.md
@@ -1,27 +1,27 @@
|
||||
# Kotatsu
|
||||
|
||||
Kotatsu is a free and open source manga reader for Android.
|
||||
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
||||
|
||||
   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
||||
[](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
|
||||
|
||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||
* Search manga by name and genres
|
||||
* Search manga by name, genres, and more filters
|
||||
* Reading history and bookmarks
|
||||
* Favourites organized by user-defined categories
|
||||
* Favorites organized by user-defined categories
|
||||
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
||||
* Tablet-optimized Material You UI
|
||||
* Standard and Webtoon-optimized reader
|
||||
* Standard and Webtoon-optimized customizable reader
|
||||
* Notifications about new chapters with updates feed
|
||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||
* Password/fingerprint protect access to the app
|
||||
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
||||
* Password/fingerprint-protected access to the app
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -53,5 +53,5 @@ install instructions.
|
||||
|
||||
### DMCA disclaimer
|
||||
|
||||
The developers of this application does not have any affiliation with the content available in the app.
|
||||
It is collecting from the sources freely available through any web browser.
|
||||
The developers of this application do not have any affiliation with the content available in the app.
|
||||
It collects content from sources that are freely available through any web browser
|
||||
|
||||
179
app/build.gradle
179
app/build.gradle
@@ -1,3 +1,5 @@
|
||||
import java.time.LocalDateTime
|
||||
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
@@ -8,16 +10,16 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 34
|
||||
buildToolsVersion = '34.0.0'
|
||||
compileSdk = 35
|
||||
buildToolsVersion = '35.0.0'
|
||||
namespace = 'org.koitharu.kotatsu'
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 657
|
||||
versionName = '7.4'
|
||||
versionCode = 697
|
||||
versionName = '7.7.5'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -37,33 +39,46 @@ 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/'
|
||||
}
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
freeCompilerArgs += [
|
||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-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',
|
||||
]
|
||||
}
|
||||
lint {
|
||||
abortOnError true
|
||||
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
|
||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources true
|
||||
@@ -72,6 +87,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 {
|
||||
@@ -81,89 +105,92 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:a9fc534ea7') {
|
||||
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.0.4'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10-RC'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC'
|
||||
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.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.2'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.4'
|
||||
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.4'
|
||||
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.0'
|
||||
//noinspection GradleDependency
|
||||
implementation('com.google.guava:guava:32.0.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.0'
|
||||
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.51.1'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.51.1'
|
||||
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:882bc0620c'
|
||||
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.3'
|
||||
implementation 'ch.acra:acra-dialog:5.11.3'
|
||||
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
|
||||
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
|
||||
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:2.14'
|
||||
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.8.1'
|
||||
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.8.1'
|
||||
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.51.1'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
|
||||
androidTestImplementation libs.hilt.android.testing
|
||||
kaptAndroidTest libs.hilt.android.compiler
|
||||
}
|
||||
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@@ -14,6 +14,8 @@
|
||||
-dontwarn org.conscrypt.**
|
||||
-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
|
||||
@@ -21,3 +23,8 @@
|
||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
||||
-keep class org.jsoup.parser.Tag
|
||||
-keep class org.jsoup.internal.StringUtil
|
||||
|
||||
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
||||
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
||||
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
||||
-keep class org.acra.sender.JobSenderService
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
package org.koitharu.kotatsu.tracker.domain
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import junit.framework.TestCase.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koitharu.kotatsu.SampleData
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TrackerTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var repository: TrackingRepository
|
||||
|
||||
@Inject
|
||||
lateinit var dataRepository: MangaDataRepository
|
||||
|
||||
@Inject
|
||||
lateinit var tracker: Tracker
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noUpdates() = runTest {
|
||||
val manga = loadManga("full.json")
|
||||
tracker.deleteTrack(manga.id)
|
||||
|
||||
tracker.checkUpdates(manga, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
||||
tracker.checkUpdates(manga, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasUpdates() = runTest {
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
val mangaFull = loadManga("full.json")
|
||||
tracker.deleteTrack(mangaFirst.id)
|
||||
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assertEquals(3, newChapters.size)
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun badIds() = runTest {
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
val mangaBad = loadManga("bad_ids.json")
|
||||
tracker.deleteTrack(mangaFirst.id)
|
||||
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun badIds2() = runTest {
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
val mangaBad = loadManga("bad_ids.json")
|
||||
val mangaFull = loadManga("full.json")
|
||||
tracker.deleteTrack(mangaFirst.id)
|
||||
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assertEquals(3, newChapters.size)
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullReset() = runTest {
|
||||
val mangaFull = loadManga("full.json")
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
val mangaEmpty = loadManga("empty.json")
|
||||
tracker.deleteTrack(mangaFull.id)
|
||||
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assertEquals(3, newChapters.size)
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncWithHistory() = runTest {
|
||||
val mangaFull = loadManga("full.json")
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
tracker.deleteTrack(mangaFull.id)
|
||||
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assertEquals(3, newChapters.size)
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||
|
||||
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
|
||||
tracker.syncWithHistory(mangaFull, chapter.id)
|
||||
|
||||
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
||||
|
||||
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
||||
tracker.syncWithHistory(mangaFull, chapter.id)
|
||||
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
}
|
||||
|
||||
private suspend fun loadManga(name: String): Manga {
|
||||
val manga = SampleData.loadAsset("manga/$name", Manga::class)
|
||||
dataRepository.storeManga(manga)
|
||||
return manga
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import org.koitharu.kotatsu.core.BaseApp
|
||||
@@ -8,6 +9,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
|
||||
class KotatsuApp : BaseApp() {
|
||||
|
||||
@@ -17,29 +19,55 @@ class KotatsuApp : BaseApp() {
|
||||
}
|
||||
|
||||
private fun enableStrictMode() {
|
||||
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
StrictModeNotifier(this)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
StrictMode.ThreadPolicy.Builder().apply {
|
||||
detectNetwork()
|
||||
detectDiskWrites()
|
||||
detectCustomSlowCalls()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
penaltyListener(notifier.executor, notifier)
|
||||
}
|
||||
}.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||
.setClassInstanceLimit(PagesCache::class.java, 1)
|
||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
.setClassInstanceLimit(PageLoader::class.java, 1)
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
StrictMode.VmPolicy.Builder().apply {
|
||||
detectActivityLeaks()
|
||||
detectLeakedSqlLiteObjects()
|
||||
detectLeakedClosableObjects()
|
||||
detectLeakedRegistrationObjects()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
||||
detectFileUriExposure()
|
||||
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||
setClassInstanceLimit(PagesCache::class.java, 1)
|
||||
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
setClassInstanceLimit(PageLoader::class.java, 1)
|
||||
setClassInstanceLimit(ReaderViewModel::class.java, 1)
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
penaltyListener(notifier.executor, notifier)
|
||||
}
|
||||
}.build()
|
||||
)
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||
.penaltyDeath()
|
||||
.detectFragmentReuse()
|
||||
.detectWrongFragmentContainer()
|
||||
.detectRetainInstanceUsage()
|
||||
.detectSetUserVisibleHint()
|
||||
.detectFragmentTagUsage()
|
||||
.build()
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
||||
detectWrongFragmentContainer()
|
||||
detectFragmentTagUsage()
|
||||
detectRetainInstanceUsage()
|
||||
detectSetUserVisibleHint()
|
||||
detectWrongNestedHierarchy()
|
||||
detectFragmentReuse()
|
||||
penaltyLog()
|
||||
if (notifier != null) {
|
||||
penaltyListener(notifier)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.koitharu.kotatsu
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Notification.BigTextStyle
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
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.util.ShareHelper
|
||||
import kotlin.math.absoluteValue
|
||||
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
class StrictModeNotifier(
|
||||
private val context: Context,
|
||||
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
|
||||
|
||||
val executor = Dispatchers.Default.asExecutor()
|
||||
|
||||
private val notificationManager by lazy {
|
||||
val nm = checkNotNull(context.getSystemService<NotificationManager>())
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.strict_mode),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
nm.createNotificationChannel(channel)
|
||||
nm
|
||||
}
|
||||
|
||||
override fun onVmViolation(v: Violation) = showNotification(v)
|
||||
|
||||
override fun onThreadViolation(v: Violation) = showNotification(v)
|
||||
|
||||
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||
|
||||
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_bug)
|
||||
.setContentTitle(context.getString(R.string.strict_mode))
|
||||
.setContentText(violation.message)
|
||||
.setStyle(
|
||||
BigTextStyle()
|
||||
.setBigContentTitle(context.getString(R.string.strict_mode))
|
||||
.setSummaryText(violation.message)
|
||||
.bigText(violation.stackTraceToString()),
|
||||
).setShowWhen(true)
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
0,
|
||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
.setAutoCancel(true)
|
||||
.setGroup(CHANNEL_ID)
|
||||
.build()
|
||||
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
|
||||
|
||||
private companion object {
|
||||
|
||||
const val CHANNEL_ID = "strict_mode"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -1,3 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||
</resources>
|
||||
<string name="strict_mode">Strict mode</string>
|
||||
</resources>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,18 +14,21 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
private const val MATCH_THRESHOLD = 0.2f
|
||||
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
|
||||
|
||||
class AlternativesUseCase @Inject constructor(
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
|
||||
|
||||
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
|
||||
val sources = getSources(manga.source)
|
||||
if (sources.isEmpty()) {
|
||||
return emptyFlow()
|
||||
@@ -34,17 +37,17 @@ class AlternativesUseCase @Inject constructor(
|
||||
return channelFlow {
|
||||
for (source in sources) {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
if (!repository.isSearchSupported) {
|
||||
if (!repository.filterCapabilities.isSearchSupported) {
|
||||
continue
|
||||
}
|
||||
launch {
|
||||
val list = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
for (item in list) {
|
||||
if (item.matches(manga)) {
|
||||
if (item.matches(manga, matchThreshold)) {
|
||||
send(item)
|
||||
}
|
||||
}
|
||||
@@ -65,16 +68,16 @@ class AlternativesUseCase @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Manga.matches(ref: Manga): Boolean {
|
||||
return matchesTitles(title, ref.title) ||
|
||||
matchesTitles(title, ref.altTitle) ||
|
||||
matchesTitles(altTitle, ref.title) ||
|
||||
matchesTitles(altTitle, ref.altTitle)
|
||||
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
|
||||
return matchesTitles(title, ref.title, threshold) ||
|
||||
matchesTitles(title, ref.altTitle, threshold) ||
|
||||
matchesTitles(altTitle, ref.title, threshold) ||
|
||||
matchesTitles(altTitle, ref.altTitle, threshold)
|
||||
|
||||
}
|
||||
|
||||
private fun matchesTitles(a: String?, b: String?): Boolean {
|
||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
||||
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
||||
}
|
||||
|
||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.koitharu.kotatsu.alternatives.domain
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.lastOrNull
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import kotlinx.coroutines.flow.withIndex
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class AutoFixUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val alternativesUseCase: AlternativesUseCase,
|
||||
private val migrateUseCase: MigrateUseCase,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
||||
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
|
||||
.getDetailsSafe()
|
||||
if (seed.isHealthy()) {
|
||||
return seed to null // no fix required
|
||||
}
|
||||
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
|
||||
.filter { it.isHealthy() }
|
||||
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||
if (best == null || best < candidate) {
|
||||
candidate
|
||||
} else {
|
||||
best
|
||||
}
|
||||
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
|
||||
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
|
||||
return seed to replacement
|
||||
}
|
||||
|
||||
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
|
||||
val repo = mangaRepositoryFactory.create(source)
|
||||
val details = if (this.chapters != null) this else repo.getDetails(this)
|
||||
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
|
||||
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
|
||||
pageUrl.toHttpUrlOrNull() != null
|
||||
}.getOrDefault(false)
|
||||
|
||||
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(source).getDetails(this)
|
||||
}.getOrDefault(this)
|
||||
|
||||
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
|
||||
|
||||
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
|
||||
private suspend fun <T> Flow<T>.selectLastWithTimeout(
|
||||
minCount: Int,
|
||||
timeout: Long,
|
||||
timeUnit: TimeUnit
|
||||
): T? = channelFlow<T?> {
|
||||
var lastValue: T? = null
|
||||
launch {
|
||||
delay(timeUnit.toMillis(timeout))
|
||||
close(InternalTimeoutException(lastValue))
|
||||
}
|
||||
withIndex().transformWhile { (index, value) ->
|
||||
lastValue = value
|
||||
emit(value)
|
||||
index < minCount && !isClosedForSend
|
||||
}.collect {
|
||||
send(it)
|
||||
}
|
||||
}.catch { e ->
|
||||
if (e is InternalTimeoutException) {
|
||||
emit(e.value as T?)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}.lastOrNull()
|
||||
|
||||
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
|
||||
|
||||
private class InternalTimeoutException(val value: Any?) : CancellationException()
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -136,7 +136,7 @@ constructor(
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
updatedAt = history.updatedAt,
|
||||
chapterId = currentChapter.id,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
@@ -173,7 +173,7 @@ constructor(
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
updatedAt = history.updatedAt,
|
||||
chapterId = newChapterId,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
|
||||
@@ -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.CircleCropTransformation
|
||||
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,8 +82,8 @@ fun alternativeAD(
|
||||
.placeholder(R.drawable.ic_web)
|
||||
.fallback(R.drawable.ic_web)
|
||||
.error(R.drawable.ic_web)
|
||||
.source(item.manga.source)
|
||||
.transformations(CircleCropTransformation())
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
@@ -18,8 +17,8 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||
@@ -31,7 +30,8 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -82,29 +82,37 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
|
||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||
when (view.id) {
|
||||
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
||||
R.id.chip_source -> startActivity(
|
||||
MangaListActivity.newIntent(
|
||||
this,
|
||||
item.manga.source,
|
||||
MangaListFilter(query = viewModel.manga.title),
|
||||
),
|
||||
)
|
||||
|
||||
R.id.button_migrate -> confirmMigration(item.manga)
|
||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmMigration(target: Manga) {
|
||||
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
||||
.setIcon(R.drawable.ic_replace)
|
||||
.setTitle(R.string.manga_migration)
|
||||
.setMessage(
|
||||
buildAlertDialog(this, isCentered = true) {
|
||||
setIcon(R.drawable.ic_replace)
|
||||
setTitle(R.string.manga_migration)
|
||||
setMessage(
|
||||
getString(
|
||||
R.string.migrate_confirmation,
|
||||
viewModel.manga.title,
|
||||
viewModel.manga.source.getTitle(this),
|
||||
viewModel.manga.source.getTitle(context),
|
||||
target.title,
|
||||
target.source.getTitle(this),
|
||||
target.source.getTitle(context),
|
||||
),
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.migrate) { _, _ ->
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.migrate) { _, _ ->
|
||||
viewModel.migrate(target)
|
||||
}.show()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
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 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.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
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AutoFixService : CoroutineIntentService() {
|
||||
|
||||
@Inject
|
||||
lateinit var autoFixUseCase: AutoFixUseCase
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
}
|
||||
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground(jobContext: IntentJobContext) {
|
||||
val title = applicationContext.getString(R.string.fixing_manga)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||
.setName(title)
|
||||
.setShowBadge(false)
|
||||
.setVibrationEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setOngoing(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(R.drawable.ic_stat_auto_fix)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
jobContext.getCancelIntent(),
|
||||
)
|
||||
.build()
|
||||
|
||||
jobContext.setForeground(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setAutoCancel(true)
|
||||
result.onSuccess { (seed, replacement) ->
|
||||
if (replacement != null) {
|
||||
notification.setLargeIcon(
|
||||
coil.execute(
|
||||
ImageRequest.Builder(applicationContext)
|
||||
.data(replacement.coverUrl)
|
||||
.mangaSourceExtra(replacement.source)
|
||||
.build(),
|
||||
).toBitmapOrNull(),
|
||||
)
|
||||
notification.setSubText(replacement.title)
|
||||
val intent = DetailsActivity.newIntent(applicationContext, replacement)
|
||||
notification.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
replacement.id.toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
),
|
||||
).setVisibility(
|
||||
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
|
||||
)
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixed))
|
||||
.setContentText(
|
||||
applicationContext.getString(
|
||||
R.string.manga_replaced,
|
||||
seed.title,
|
||||
seed.source.getTitle(applicationContext),
|
||||
replacement.title,
|
||||
replacement.source.getTitle(applicationContext),
|
||||
),
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_stat_done)
|
||||
} else {
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
||||
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||
.setContentText(
|
||||
if (error is AutoFixUseCase.NoAlternativesException) {
|
||||
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||
} else {
|
||||
error.getDisplayMessage(applicationContext.resources)
|
||||
},
|
||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
||||
notification.addAction(
|
||||
R.drawable.ic_alert_outline,
|
||||
applicationContext.getString(R.string.report),
|
||||
reportIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
return notification.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DATA_IDS = "ids"
|
||||
private const val TAG = "auto_fix"
|
||||
private const val CHANNEL_ID = "auto_fix"
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 38
|
||||
|
||||
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
|
||||
val intent = Intent(context, AutoFixService::class.java)
|
||||
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,6 @@ data class Bookmark(
|
||||
val percent: Float,
|
||||
) : ListModel {
|
||||
|
||||
val directImageUrl: String?
|
||||
get() = if (isImageUrlDirect()) imageUrl else null
|
||||
|
||||
val imageLoadData: Any
|
||||
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.bookmarks.ui
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -13,7 +14,7 @@ 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
|
||||
@@ -46,7 +47,7 @@ class AllBookmarksFragment :
|
||||
BaseFragment<FragmentListSimpleBinding>(),
|
||||
ListStateHolderListener,
|
||||
OnListItemClickListener<Bookmark>,
|
||||
ListSelectionController.Callback2,
|
||||
ListSelectionController.Callback,
|
||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
||||
|
||||
@Inject
|
||||
@@ -129,7 +130,11 @@ class AllBookmarksFragment :
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||
return selectionController?.onItemLongClick(item.pageId) ?: false
|
||||
return selectionController?.onItemLongClick(view, item.pageId) ?: false
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
||||
return selectionController?.onItemContextClick(view, item.pageId) ?: false
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
@@ -148,23 +153,23 @@ class AllBookmarksFragment :
|
||||
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
mode: ActionMode,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu,
|
||||
): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||
menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(
|
||||
controller: ListSelectionController,
|
||||
mode: ActionMode,
|
||||
mode: ActionMode?,
|
||||
item: MenuItem,
|
||||
): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
val ids = selectionController?.snapshot() ?: return false
|
||||
viewModel.removeBookmarks(ids)
|
||||
mode.finish()
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,19 +23,15 @@ fun bookmarkLargeAD(
|
||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
|
||||
binding.root.setOnClickListener(listener)
|
||||
binding.root.setOnLongClickListener(listener)
|
||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||
|
||||
bind {
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||
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,
|
||||
@@ -21,19 +23,15 @@ fun bookmarkListAD(
|
||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
|
||||
binding.root.setOnClickListener(listener)
|
||||
binding.root.setOnLongClickListener(listener)
|
||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||
|
||||
bind {
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||
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
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import okhttp3.internal.userAgent
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
@@ -45,7 +44,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
||||
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
|
||||
@@ -9,9 +9,10 @@ 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
|
||||
@@ -21,14 +22,14 @@ 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)
|
||||
@@ -41,9 +42,9 @@ class CaptchaNotifier(
|
||||
.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 +85,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,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
@@ -28,10 +29,10 @@ import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
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
|
||||
|
||||
@@ -175,18 +176,17 @@ 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, TaggedActivityResult>() {
|
||||
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
|
||||
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
||||
return newIntent(context, input)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||
return TaggedActivityResult(TAG, resultCode)
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return resultCode == Activity.RESULT_OK
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
package org.koitharu.kotatsu.core
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
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
|
||||
@@ -28,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
|
||||
@@ -44,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
|
||||
@@ -81,9 +87,7 @@ interface AppModule {
|
||||
@Singleton
|
||||
fun provideMangaDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): MangaDatabase {
|
||||
return MangaDatabase(context)
|
||||
}
|
||||
): MangaDatabase = MangaDatabase(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -94,6 +98,7 @@ interface AppModule {
|
||||
imageProxyInterceptor: ImageProxyInterceptor,
|
||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||
networkStateProvider: Provider<NetworkState>,
|
||||
): ImageLoader {
|
||||
val diskCacheFactory = {
|
||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||
@@ -105,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
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.acra.ACRA
|
||||
@@ -28,6 +29,9 @@ import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||
import java.security.Security
|
||||
import javax.inject.Inject
|
||||
@@ -60,6 +64,13 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
@Inject
|
||||
lateinit var workManagerProvider: Provider<WorkManager>
|
||||
|
||||
@Inject
|
||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||
|
||||
@Inject
|
||||
@LocalStorageChanges
|
||||
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
@@ -67,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
|
||||
@@ -82,6 +96,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
}
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
setupDatabaseObservers()
|
||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||
}
|
||||
workScheduleManager.init()
|
||||
WorkServiceStopHelper(workManagerProvider).setup()
|
||||
|
||||
@@ -5,9 +5,11 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.BadParcelableException
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.report
|
||||
|
||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
@@ -22,12 +24,15 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
private const val EXTRA_ERROR = "err"
|
||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
||||
|
||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
|
||||
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)
|
||||
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
|
||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||
} catch (e: BadParcelableException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.koitharu.kotatsu.core
|
||||
|
||||
import android.content.Context
|
||||
import com.google.auto.service.AutoService
|
||||
import org.acra.builder.ReportBuilder
|
||||
import org.acra.config.CoreConfiguration
|
||||
import org.acra.config.ReportingAdministrator
|
||||
|
||||
@AutoService(ReportingAdministrator::class)
|
||||
class ErrorReportingAdmin : ReportingAdministrator {
|
||||
|
||||
override fun shouldStartCollecting(
|
||||
context: Context,
|
||||
config: CoreConfiguration,
|
||||
reportBuilder: ReportBuilder
|
||||
): Boolean {
|
||||
return reportBuilder.exception?.isDeadOs() != true
|
||||
}
|
||||
|
||||
private fun Throwable.isDeadOs(): Boolean {
|
||||
val className = javaClass.simpleName
|
||||
return className == "DeadSystemException" || className == "DeadSystemRuntimeException" || cause?.isDeadOs() == true
|
||||
}
|
||||
}
|
||||
@@ -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,13 +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
|
||||
@@ -35,25 +33,29 @@ class BackupZipInput private constructor(val file: File) : Closeable {
|
||||
zipFile.close()
|
||||
}
|
||||
|
||||
fun cleanupAsync() {
|
||||
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
|
||||
runCatching {
|
||||
close()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
fun closeAndDelete() {
|
||||
closeQuietly()
|
||||
file.delete()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(file: File): BackupZipInput = try {
|
||||
val res = BackupZipInput(file)
|
||||
if (res.zipFile.getEntry("index") == null) {
|
||||
throw BadBackupFormatException(null)
|
||||
fun from(file: File): BackupZipInput {
|
||||
var res: BackupZipInput? = null
|
||||
return try {
|
||||
res = BackupZipInput(file)
|
||||
if (res.zipFile.getEntry("index") == null) {
|
||||
throw BadBackupFormatException(null)
|
||||
}
|
||||
res
|
||||
} catch (exception: Throwable) {
|
||||
res?.closeQuietly()
|
||||
throw if (exception is ZipException) {
|
||||
BadBackupFormatException(exception)
|
||||
} else {
|
||||
exception
|
||||
}
|
||||
}
|
||||
res
|
||||
} catch (e: ZipException) {
|
||||
throw BadBackupFormatException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||
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.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -50,6 +51,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouritesDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
||||
@@ -60,14 +63,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 = 22
|
||||
const val DATABASE_VERSION = 23
|
||||
|
||||
@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,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||
],
|
||||
version = DATABASE_VERSION,
|
||||
)
|
||||
@@ -98,6 +101,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
abstract fun getSourcesDao(): MangaSourcesDao
|
||||
|
||||
abstract fun getStatsDao(): StatsDao
|
||||
|
||||
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
||||
}
|
||||
|
||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
@@ -122,6 +127,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration19To20(),
|
||||
Migration20To21(),
|
||||
Migration21To22(),
|
||||
Migration22To23(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import java.util.LinkedList
|
||||
|
||||
class MangaQueryBuilder(
|
||||
private val table: String,
|
||||
private val conditionCallback: ConditionCallback
|
||||
) {
|
||||
|
||||
private var filterOptions: Collection<ListFilterOption> = emptyList()
|
||||
private var whereConditions = LinkedList<String>()
|
||||
private var orderBy: String? = null
|
||||
private var groupBy: String? = null
|
||||
private var extraJoins: String? = null
|
||||
private var limit: Int = 0
|
||||
|
||||
fun filters(options: Collection<ListFilterOption>) = apply {
|
||||
filterOptions = options
|
||||
}
|
||||
|
||||
fun where(condition: String) = apply {
|
||||
whereConditions.add(condition)
|
||||
}
|
||||
|
||||
fun orderBy(orderBy: String?) = apply {
|
||||
this@MangaQueryBuilder.orderBy = orderBy
|
||||
}
|
||||
|
||||
fun groupBy(groupBy: String?) = apply {
|
||||
this@MangaQueryBuilder.groupBy = groupBy
|
||||
}
|
||||
|
||||
fun limit(limit: Int) = apply {
|
||||
this@MangaQueryBuilder.limit = limit
|
||||
}
|
||||
|
||||
fun join(join: String?) = apply {
|
||||
extraJoins = join
|
||||
}
|
||||
|
||||
fun build() = buildString {
|
||||
append("SELECT * FROM ")
|
||||
append(table)
|
||||
extraJoins?.let {
|
||||
append(' ')
|
||||
append(it)
|
||||
}
|
||||
if (whereConditions.isNotEmpty()) {
|
||||
whereConditions.joinTo(
|
||||
buffer = this,
|
||||
prefix = " WHERE ",
|
||||
separator = " AND ",
|
||||
)
|
||||
}
|
||||
if (filterOptions.isNotEmpty()) {
|
||||
if (whereConditions.isEmpty()) {
|
||||
append(" WHERE")
|
||||
} else {
|
||||
append(" AND")
|
||||
}
|
||||
var isFirst = true
|
||||
val groupedOptions = filterOptions.groupBy { it.groupKey }
|
||||
for ((_, group) in groupedOptions) {
|
||||
if (group.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
if (isFirst) {
|
||||
isFirst = false
|
||||
append(' ')
|
||||
} else {
|
||||
append(" AND ")
|
||||
}
|
||||
if (group.size > 1) {
|
||||
group.joinTo(
|
||||
buffer = this,
|
||||
separator = " OR ",
|
||||
prefix = "(",
|
||||
postfix = ")",
|
||||
transform = ::getConditionOrThrow,
|
||||
)
|
||||
} else {
|
||||
append(getConditionOrThrow(group.single()))
|
||||
}
|
||||
}
|
||||
}
|
||||
groupBy?.let {
|
||||
append(" GROUP BY ")
|
||||
append(it)
|
||||
}
|
||||
orderBy?.let {
|
||||
append(" ORDER BY ")
|
||||
append(it)
|
||||
}
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}.let { SimpleSQLiteQuery(it) }
|
||||
|
||||
private fun getConditionOrThrow(option: ListFilterOption): String = when (option) {
|
||||
is ListFilterOption.Inverted -> "NOT(${getConditionOrThrow(option.option)})"
|
||||
else -> requireNotNull(conditionCallback.getCondition(option)) {
|
||||
"Unsupported filter option $option"
|
||||
}
|
||||
}
|
||||
|
||||
fun interface ConditionCallback {
|
||||
|
||||
fun getCondition(option: ListFilterOption): String?
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.*
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
|
||||
@@ -13,6 +15,9 @@ abstract class PreferencesDao {
|
||||
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
||||
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
||||
|
||||
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
|
||||
abstract suspend fun resetColorFilters()
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(pref: MangaPrefsEntity)
|
||||
}
|
||||
|
||||
@@ -4,36 +4,59 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.Transaction
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
||||
|
||||
@Dao
|
||||
interface TrackLogsDao {
|
||||
abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
||||
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
||||
fun observeAll(
|
||||
limit: Int,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
): Flow<List<TrackLogWithManga>> = observeAllImpl(
|
||||
MangaQueryBuilder("track_logs", this)
|
||||
.filters(filterOptions)
|
||||
.limit(limit)
|
||||
.orderBy("created_at DESC")
|
||||
.build(),
|
||||
)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
|
||||
fun observeUnreadCount(): Flow<Int>
|
||||
abstract fun observeUnreadCount(): Flow<Int>
|
||||
|
||||
@Query("DELETE FROM track_logs")
|
||||
suspend fun clear()
|
||||
abstract suspend fun clear()
|
||||
|
||||
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
|
||||
suspend fun markAsRead(id: Long)
|
||||
abstract suspend fun markAsRead(id: Long)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: TrackLogEntity): Long
|
||||
abstract suspend fun insert(entity: TrackLogEntity): Long
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||
suspend fun gc()
|
||||
abstract suspend fun gc()
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
|
||||
suspend fun trim(size: Int)
|
||||
abstract suspend fun trim(size: Int)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_logs")
|
||||
suspend fun count(): Int
|
||||
abstract suspend fun count(): Int
|
||||
|
||||
@Transaction
|
||||
@RawQuery(observedEntities = [TrackLogEntity::class])
|
||||
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<TrackLogWithManga>>
|
||||
|
||||
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
||||
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id)"
|
||||
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id AND favourites.category_id = ${option.category.id})"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = track_logs.manga_id AND tag_id = ${option.tagId})"
|
||||
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = track_logs.manga_id) = 1"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||
|
||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
|
||||
|
||||
// Model to entity
|
||||
|
||||
fun Manga.toEntity() = MangaEntity(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration22To23 : Migration(22, 23) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
class CaughtException(cause: Throwable, override val message: String?) : RuntimeException(cause)
|
||||
class CaughtException(cause: Throwable) : RuntimeException("${cause.javaClass.simpleName}(${cause.message})", cause)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import okhttp3.Headers
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
class IncompatiblePluginException(
|
||||
val name: String?,
|
||||
cause: Throwable?,
|
||||
) : RuntimeException(cause)
|
||||
@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.exceptions
|
||||
import okio.IOException
|
||||
|
||||
class NoDataReceivedException(
|
||||
private val url: String,
|
||||
val url: String,
|
||||
) : IOException("No data has been received from $url")
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import java.net.ProtocolException
|
||||
|
||||
class ProxyConfigException : ProtocolException("Wrong proxy configuration")
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import okio.IOException
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class TooManyRequestExceptions(
|
||||
val url: String,
|
||||
val retryAt: Instant?,
|
||||
) : IOException() {
|
||||
val retryAfter: Long
|
||||
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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(
|
||||
@@ -32,7 +33,7 @@ class DialogErrorObserver(
|
||||
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
|
||||
} else if (value is ParseException) {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
if (fm != null && value.isSerializable()) {
|
||||
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
|
||||
ErrorDetailsDialog.show(fm, value, value.url)
|
||||
}
|
||||
|
||||
@@ -2,67 +2,55 @@ package org.koitharu.kotatsu.core.exceptions.resolve
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.ActivityResultCaller
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
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.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.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
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
|
||||
class ExceptionResolver @AssistedInject constructor(
|
||||
@Assisted private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
private val activity: FragmentActivity?
|
||||
private val fragment: Fragment?
|
||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
||||
|
||||
val context: Context?
|
||||
get() = activity ?: fragment?.context
|
||||
|
||||
constructor(activity: FragmentActivity) {
|
||||
this.activity = activity
|
||||
fragment = null
|
||||
sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
||||
cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
|
||||
constructor(fragment: Fragment) {
|
||||
this.fragment = fragment
|
||||
activity = null
|
||||
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
|
||||
cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this)
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: TaggedActivityResult) {
|
||||
continuations.remove(result.tag)?.resume(result.isSuccess)
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
|
||||
fun showDetails(e: Throwable, url: String?) {
|
||||
ErrorDetailsDialog.show(getFragmentManager(), e, url)
|
||||
ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url)
|
||||
}
|
||||
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
@@ -74,6 +62,13 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
false
|
||||
}
|
||||
|
||||
is ProxyConfigException -> {
|
||||
host.withContext {
|
||||
startActivity(SettingsActivity.newProxySettingsIntent(this))
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
@@ -84,6 +79,20 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
false
|
||||
}
|
||||
|
||||
is ScrobblerAuthRequiredException -> {
|
||||
val authHelper = scrobblerAuthHelperProvider.get()
|
||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure {
|
||||
showDetails(it, null)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
@@ -97,53 +106,68 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
context?.run {
|
||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||
}
|
||||
private fun openInBrowser(url: String) = host.withContext {
|
||||
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
context?.run {
|
||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||
}
|
||||
private fun openAlternatives(manga: Manga) = host.withContext {
|
||||
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||
}
|
||||
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = context ?: return
|
||||
val settings = getAppSettings(ctx)
|
||||
val ctx = host.getContext() ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.ignore_ssl_errors)
|
||||
.setMessage(R.string.ignore_ssl_errors_summary)
|
||||
.setPositiveButton(R.string.apply) { _, _ ->
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
|
||||
ctx.findActivity()?.finishAffinity()
|
||||
}.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun getAppSettings(context: Context): AppSettings {
|
||||
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
|
||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||
getContext()?.apply(block)
|
||||
}
|
||||
|
||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||
interface Host : ActivityResultCaller {
|
||||
|
||||
fun getChildFragmentManager(): FragmentManager
|
||||
|
||||
fun getContext(): Context?
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(host: Host): ExceptionResolver
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
|
||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
is ProxyConfigException -> R.string.settings
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -33,7 +34,7 @@ class SnackbarErrorObserver(
|
||||
}
|
||||
} else if (value is ParseException) {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
if (fm != null && value.isSerializable()) {
|
||||
snackbar.setAction(R.string.details) {
|
||||
ErrorDetailsDialog.show(fm, value, value.url)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
package org.koitharu.kotatsu.core.fs
|
||||
|
||||
import android.os.Build
|
||||
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.koitharu.kotatsu.core.util.CloseableSequence
|
||||
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
class FileSequence(private val dir: File) : Sequence<File> {
|
||||
sealed interface FileSequence : CloseableSequence<File> {
|
||||
|
||||
override fun iterator(): Iterator<File> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val stream = Files.newDirectoryStream(dir.toPath())
|
||||
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
|
||||
} else {
|
||||
dir.listFiles().orEmpty().iterator()
|
||||
}
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
class StreamImpl(dir: File) : FileSequence {
|
||||
|
||||
private val stream = Files.newDirectoryStream(dir.toPath())
|
||||
|
||||
override fun iterator(): Iterator<File> = MappingIterator(stream.iterator(), Path::toFile)
|
||||
|
||||
override fun close() = stream.close()
|
||||
}
|
||||
|
||||
class ListImpl(dir: File) : FileSequence {
|
||||
|
||||
private val list = dir.listFiles().orEmpty()
|
||||
|
||||
override fun iterator(): Iterator<File> = list.iterator()
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,94 @@
|
||||
package org.koitharu.kotatsu.core.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import org.aomedia.avif.android.AvifDecoder
|
||||
import org.aomedia.avif.android.AvifDecoder.Info
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.file.Files
|
||||
|
||||
object BitmapDecoderCompat {
|
||||
|
||||
private const val FORMAT_AVIF = "avif"
|
||||
|
||||
@Blocking
|
||||
fun decode(file: File): Bitmap = when (val format = getMimeType(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: MediaType?, 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))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
|
||||
} else {
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
|
||||
}
|
||||
|
||||
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 android.webkit.MimeTypeMap
|
||||
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.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 = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")),
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.subdir
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
private const val DIR = "logs"
|
||||
private const val FLUSH_DELAY = 2_000L
|
||||
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
||||
|
||||
class FileLogger(
|
||||
context: Context,
|
||||
private val settings: AppSettings,
|
||||
name: String,
|
||||
) {
|
||||
|
||||
val file by lazy {
|
||||
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
||||
File(dir, "$name.log")
|
||||
}
|
||||
val isEnabled: Boolean
|
||||
get() = settings.isLoggingEnabled
|
||||
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
|
||||
private val buffer = ConcurrentLinkedQueue<String>()
|
||||
private val mutex = Mutex()
|
||||
private var flushJob: Job? = null
|
||||
|
||||
fun log(message: String, e: Throwable? = null) {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
val text = buildString {
|
||||
append(dateTimeFormatter.format(LocalDateTime.now()))
|
||||
append(": ")
|
||||
if (e != null) {
|
||||
append("E!")
|
||||
}
|
||||
append(message)
|
||||
if (e != null) {
|
||||
append(' ')
|
||||
append(e.stackTraceToString())
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
buffer.add(text)
|
||||
postFlush()
|
||||
}
|
||||
|
||||
inline fun log(messageProducer: () -> String) {
|
||||
if (isEnabled) {
|
||||
log(messageProducer())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun flush() {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
flushJob?.cancelAndJoin()
|
||||
flushImpl()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun flushBlocking() {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
runBlockingSafe { flushJob?.cancelAndJoin() }
|
||||
runBlockingSafe { flushImpl() }
|
||||
}
|
||||
|
||||
private fun postFlush() {
|
||||
if (flushJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
delay(FLUSH_DELAY)
|
||||
runCatchingCancellable {
|
||||
flushImpl()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun flushImpl() = withContext(NonCancellable) {
|
||||
mutex.withLock {
|
||||
if (buffer.isEmpty()) {
|
||||
return@withContext
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
if (file.length() > MAX_SIZE_BYTES) {
|
||||
rotate()
|
||||
}
|
||||
FileOutputStream(file, true).use {
|
||||
while (true) {
|
||||
val message = buffer.poll() ?: break
|
||||
it.write(message.toByteArray())
|
||||
it.write('\n'.code)
|
||||
}
|
||||
it.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun rotate() {
|
||||
val length = file.length()
|
||||
val bakFile = File(file.parentFile, file.name + ".bak")
|
||||
file.renameTo(bakFile)
|
||||
bakFile.inputStream().use { input ->
|
||||
input.skip(length - MAX_SIZE_BYTES / 2)
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
bakFile.delete()
|
||||
}
|
||||
|
||||
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
|
||||
runBlocking(NonCancellable) { block() }
|
||||
} catch (_: InterruptedException) {
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class TrackerLogger
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncLogger
|
||||
@@ -1,40 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.collection.arraySetOf
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ElementsIntoSet
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object LoggersModule {
|
||||
|
||||
@Provides
|
||||
@TrackerLogger
|
||||
fun provideTrackerLogger(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = FileLogger(context, settings, "tracker")
|
||||
|
||||
@Provides
|
||||
@SyncLogger
|
||||
fun provideSyncLogger(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = FileLogger(context, settings, "sync")
|
||||
|
||||
@Provides
|
||||
@ElementsIntoSet
|
||||
fun provideAllLoggers(
|
||||
@TrackerLogger trackerLogger: FileLogger,
|
||||
@SyncLogger syncLogger: FileLogger,
|
||||
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
||||
trackerLogger,
|
||||
syncLogger,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
@Deprecated("")
|
||||
enum class GenericSortOrder(
|
||||
@StringRes val titleResId: Int,
|
||||
val ascending: SortOrder,
|
||||
val descending: SortOrder,
|
||||
) {
|
||||
|
||||
UPDATED(R.string.updated, SortOrder.UPDATED_ASC, SortOrder.UPDATED),
|
||||
RATING(R.string.by_rating, SortOrder.RATING_ASC, SortOrder.RATING),
|
||||
POPULARITY(R.string.popularity, SortOrder.POPULARITY_ASC, SortOrder.POPULARITY),
|
||||
DATE(R.string.by_date, SortOrder.NEWEST_ASC, SortOrder.NEWEST),
|
||||
NAME(R.string.by_name, SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL_DESC),
|
||||
;
|
||||
|
||||
operator fun get(direction: SortDirection): SortOrder = when (direction) {
|
||||
SortDirection.ASC -> ascending
|
||||
SortDirection.DESC -> descending
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun of(order: SortOrder): GenericSortOrder = entries.first { e ->
|
||||
e.ascending == order || e.descending == order
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.collection.MutableObjectIntMap
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.strikeThrough
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
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
|
||||
|
||||
@@ -24,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
|
||||
@@ -68,9 +71,16 @@ val ContentRating.titleResId: Int
|
||||
ContentRating.ADULT -> R.string.rating_adult
|
||||
}
|
||||
|
||||
fun Manga.findChapter(id: Long): MangaChapter? {
|
||||
return chapters?.findById(id)
|
||||
}
|
||||
@get:StringRes
|
||||
val Demographic.titleResId: Int
|
||||
get() = when (this) {
|
||||
Demographic.SHOUNEN -> R.string.demographic_shounen
|
||||
Demographic.SHOUJO -> R.string.demographic_shoujo
|
||||
Demographic.SEINEN -> R.string.demographic_seinen
|
||||
Demographic.JOSEI -> R.string.demographic_josei
|
||||
Demographic.KODOMO -> R.string.demographic_kodomo
|
||||
Demographic.NONE -> R.string.none
|
||||
}
|
||||
|
||||
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
val ch = chapters
|
||||
@@ -110,6 +120,9 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
val Manga.isLocal: Boolean
|
||||
get() = source == LocalMangaSource
|
||||
|
||||
val Manga.isBroken: Boolean
|
||||
get() = source == UnknownMangaSource
|
||||
|
||||
val Manga.appUrl: Uri
|
||||
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
||||
.appendQueryParameter("source", source.name)
|
||||
@@ -117,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
|
||||
@@ -138,3 +145,26 @@ fun Manga.chaptersCount(): Int {
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
fun MangaListFilter.getSummary() = buildSpannedString {
|
||||
if (!query.isNullOrEmpty()) {
|
||||
append(query)
|
||||
if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) {
|
||||
append(' ')
|
||||
append('(')
|
||||
appendTagsSummary(this@getSummary)
|
||||
append(')')
|
||||
}
|
||||
} else {
|
||||
appendTagsSummary(this@getSummary)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
|
||||
filter.tags.joinTo(this) { it.title }
|
||||
if (filter.tagsExclude.isNotEmpty()) {
|
||||
strikeThrough {
|
||||
filter.tagsExclude.joinTo(this) { it.title }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ data class MangaHistory(
|
||||
val page: Int,
|
||||
val scroll: Int,
|
||||
val percent: Float,
|
||||
val chaptersCount: Int,
|
||||
) : Parcelable
|
||||
|
||||
@@ -43,6 +43,8 @@ fun MangaSource(name: String?): MangaSource {
|
||||
return UnknownMangaSource
|
||||
}
|
||||
|
||||
fun Collection<String>.toMangaSources() = map(::MangaSource)
|
||||
|
||||
fun MangaSource.isNsfw(): Boolean = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.isNsfw()
|
||||
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||
@@ -56,13 +58,26 @@ val ContentType.titleResId
|
||||
ContentType.HENTAI -> R.string.content_type_hentai
|
||||
ContentType.COMICS -> R.string.content_type_comics
|
||||
ContentType.OTHER -> R.string.content_type_other
|
||||
ContentType.MANHWA -> R.string.content_type_manhwa
|
||||
ContentType.MANHUA -> R.string.content_type_manhua
|
||||
ContentType.NOVEL -> R.string.content_type_novel
|
||||
ContentType.ONE_SHOT -> R.string.content_type_one_shot
|
||||
ContentType.DOUJINSHI -> R.string.content_type_doujinshi
|
||||
ContentType.IMAGE_SET -> R.string.content_type_image_set
|
||||
ContentType.ARTIST_CG -> R.string.content_type_artist_cg
|
||||
ContentType.GAME_CG -> R.string.content_type_game_cg
|
||||
}
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getSummary(context)
|
||||
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
|
||||
mangaSource.unwrap()
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
|
||||
is MangaParserSource -> {
|
||||
val type = context.getString(contentType.titleResId)
|
||||
val locale = locale.toLocale().getDisplayName(context)
|
||||
val type = context.getString(source.contentType.titleResId)
|
||||
val locale = source.locale.toLocale().getDisplayName(context)
|
||||
context.getString(R.string.source_summary_pattern, type, locale)
|
||||
}
|
||||
|
||||
@@ -71,11 +86,10 @@ fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun MangaSource.getTitle(context: Context): String = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getTitle(context)
|
||||
is MangaParserSource -> title
|
||||
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
||||
is MangaParserSource -> source.title
|
||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||
is ExternalMangaSource -> resolveName(context)
|
||||
is ExternalMangaSource -> source.resolveName(context)
|
||||
else -> context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
enum class SortDirection {
|
||||
|
||||
ASC, DESC;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import org.koitharu.kotatsu.core.util.ext.readEnumSet
|
||||
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.writeEnumSet
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
|
||||
object MangaListFilterParceler : Parceler<MangaListFilter> {
|
||||
|
||||
override fun MangaListFilter.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(query)
|
||||
parcel.writeParcelable(ParcelableMangaTags(tags), 0)
|
||||
parcel.writeParcelable(ParcelableMangaTags(tagsExclude), 0)
|
||||
parcel.writeSerializable(locale)
|
||||
parcel.writeSerializable(originalLocale)
|
||||
parcel.writeEnumSet(states)
|
||||
parcel.writeEnumSet(contentRating)
|
||||
parcel.writeEnumSet(types)
|
||||
parcel.writeEnumSet(demographics)
|
||||
parcel.writeInt(year)
|
||||
parcel.writeInt(yearFrom)
|
||||
parcel.writeInt(yearTo)
|
||||
}
|
||||
|
||||
override fun create(parcel: Parcel) = MangaListFilter(
|
||||
query = parcel.readString(),
|
||||
tags = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
|
||||
tagsExclude = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
|
||||
locale = parcel.readSerializableCompat(),
|
||||
originalLocale = parcel.readSerializableCompat(),
|
||||
states = parcel.readEnumSet<MangaState>().orEmpty(),
|
||||
contentRating = parcel.readEnumSet<ContentRating>().orEmpty(),
|
||||
types = parcel.readEnumSet<ContentType>().orEmpty(),
|
||||
demographics = parcel.readEnumSet<Demographic>().orEmpty(),
|
||||
year = parcel.readInt(),
|
||||
yearFrom = parcel.readInt(),
|
||||
yearTo = parcel.readInt(),
|
||||
)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@TypeParceler<MangaListFilter, MangaListFilterParceler>
|
||||
data class ParcelableMangaListFilter(val filter: MangaListFilter) : Parcelable
|
||||
@@ -1,8 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.ProxySelector
|
||||
@@ -31,9 +32,12 @@ class AppProxySelector(
|
||||
val type = settings.proxyType
|
||||
val address = settings.proxyAddress
|
||||
val port = settings.proxyPort
|
||||
if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) {
|
||||
if (type == Proxy.Type.DIRECT) {
|
||||
return Proxy.NO_PROXY
|
||||
}
|
||||
if (address.isNullOrEmpty() || port == 0) {
|
||||
throw ProxyConfigException()
|
||||
}
|
||||
cachedProxy?.let {
|
||||
val addr = it.address() as? InetSocketAddress
|
||||
if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) {
|
||||
|
||||
@@ -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,16 +31,18 @@ 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()
|
||||
repository?.headers?.let {
|
||||
.removeAll(CommonHeaders.MANGA_SOURCE)
|
||||
repository?.getRequestHeaders()?.let {
|
||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AndroidRuntimeException
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -19,6 +18,7 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Provider
|
||||
@@ -40,9 +40,10 @@ interface NetworkModule {
|
||||
@Singleton
|
||||
fun provideCookieJar(
|
||||
@ApplicationContext context: Context
|
||||
): MutableCookieJar = try {
|
||||
): MutableCookieJar = runCatching {
|
||||
AndroidCookieJar()
|
||||
} catch (e: AndroidRuntimeException) {
|
||||
}.getOrElse { e ->
|
||||
e.printStackTraceDebug()
|
||||
// WebView is not available
|
||||
PreferencesCookieJar(context)
|
||||
}
|
||||
@@ -73,7 +74,7 @@ interface NetworkModule {
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
disableCertificateVerification()
|
||||
} else {
|
||||
installExtraCertsificates(contextProvider.get())
|
||||
installExtraCertificates(contextProvider.get())
|
||||
}
|
||||
cache(cache)
|
||||
addInterceptor(GZipInterceptor())
|
||||
|
||||
@@ -3,28 +3,27 @@ package org.koitharu.kotatsu.core.network
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
import java.time.Instant
|
||||
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RateLimitInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code == 429) {
|
||||
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
|
||||
val request = response.request
|
||||
response.closeQuietly()
|
||||
throw TooManyRequestExceptions(
|
||||
url = request.url.toString(),
|
||||
retryAt = retryDate,
|
||||
retryAfter = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryAfter() ?: 0L,
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun String.parseRetryDate(): Instant? {
|
||||
return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
|
||||
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
|
||||
private fun String.parseRetryAfter(): Long {
|
||||
return toLongOrNull()?.let { TimeUnit.SECONDS.toMillis(it) }
|
||||
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fun OkHttpClient.Builder.disableCertificateVerification() = also { builder ->
|
||||
}
|
||||
}
|
||||
|
||||
fun OkHttpClient.Builder.installExtraCertsificates(context: Context) = also { builder ->
|
||||
fun OkHttpClient.Builder.installExtraCertificates(context: Context) = also { builder ->
|
||||
val certificatesBuilder = HandshakeCertificates.Builder()
|
||||
.addPlatformTrustedCertificates()
|
||||
val assets = context.assets.list("").orEmpty()
|
||||
|
||||
@@ -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
|
||||
@@ -27,9 +28,9 @@ 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
|
||||
@@ -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(),
|
||||
@@ -180,7 +181,7 @@ class AppShortcutManager @Inject constructor(
|
||||
.setLongLabel(title)
|
||||
.setIcon(icon)
|
||||
.setLongLived(true)
|
||||
.setIntent(MangaListActivity.newIntent(context, source))
|
||||
.setIntent(MangaListActivity.newIntent(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,10 +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()
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.graphics.Rect as AndroidRect
|
||||
|
||||
class BitmapWrapper private constructor(
|
||||
private val androidBitmap: AndroidBitmap,
|
||||
) : Bitmap {
|
||||
) : Bitmap, AutoCloseable {
|
||||
|
||||
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
|
||||
|
||||
@@ -24,17 +24,21 @@ class BitmapWrapper private constructor(
|
||||
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
androidBitmap.recycle()
|
||||
}
|
||||
|
||||
fun compressTo(output: OutputStream) {
|
||||
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
|
||||
fun create(width: Int, height: Int) = BitmapWrapper(
|
||||
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
|
||||
)
|
||||
|
||||
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
|
||||
fun create(bitmap: AndroidBitmap) = BitmapWrapper(
|
||||
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
|
||||
@@ -24,14 +25,17 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
override val states: Set<MangaState>
|
||||
get() = emptySet()
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = emptySet()
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = SortOrder.NEWEST
|
||||
set(value) = Unit
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = false
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = false
|
||||
override val isSearchSupported: Boolean
|
||||
get() = false
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
@@ -39,9 +31,7 @@ class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = stub(null)
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
|
||||
|
||||
|
||||
@@ -46,11 +46,16 @@ class MangaDataRepository @Inject constructor(
|
||||
cfBrightness = colorFilter?.brightness ?: 0f,
|
||||
cfContrast = colorFilter?.contrast ?: 0f,
|
||||
cfInvert = colorFilter?.isInverted ?: false,
|
||||
cfGrayscale = colorFilter?.isGrayscale ?: false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetColorFilters() {
|
||||
db.getPreferencesDao().resetColorFilters()
|
||||
}
|
||||
|
||||
suspend fun getReaderMode(mangaId: Long): ReaderMode? {
|
||||
return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
|
||||
}
|
||||
|
||||
@@ -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,23 +44,16 @@ 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? {
|
||||
if (!title.isNullOrEmpty()) {
|
||||
val list = getList(0, MangaListFilter.Search(title))
|
||||
val list = getList(0, null, MangaListFilter(query = title))
|
||||
if (url != null) {
|
||||
list.find { it.url == url }?.let {
|
||||
return it
|
||||
@@ -80,17 +72,15 @@ class MangaLinkResolver @Inject constructor(
|
||||
}.ifNullOrEmpty {
|
||||
seed.author
|
||||
} ?: return@runCatchingCancellable null
|
||||
val seedList = getList(0, MangaListFilter.Search(seedTitle))
|
||||
val seedList = getList(0, null, MangaListFilter(query = seedTitle))
|
||||
seedList.first { x -> x.url == url }
|
||||
}.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,7 +2,6 @@ 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
|
||||
@@ -16,19 +15,21 @@ 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
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.requireBody
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.core.util.ext.use
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||
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 java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -76,32 +77,23 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
|
||||
val image = response.requireBody().byteStream()
|
||||
|
||||
val opts = BitmapFactory.Options()
|
||||
opts.inMutable = true
|
||||
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
|
||||
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
|
||||
|
||||
val body = Buffer().also {
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder()
|
||||
.body(body)
|
||||
.build()
|
||||
return response.map { body ->
|
||||
BitmapDecoderCompat.decode(body.byteStream(), body.contentType(), isMutable = true).use { bitmap ->
|
||||
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
|
||||
Buffer().also {
|
||||
result.compressTo(it.outputStream())
|
||||
}.asResponseBody("image/jpeg".toMediaType())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap {
|
||||
return BitmapWrapper.create(width, height)
|
||||
}
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView {
|
||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
|
||||
private fun obtainWebViewUserAgent(): String {
|
||||
|
||||
@@ -13,18 +13,16 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.collections.set
|
||||
@@ -35,19 +33,11 @@ interface MangaRepository {
|
||||
|
||||
val sortOrders: Set<SortOrder>
|
||||
|
||||
val states: Set<MangaState>
|
||||
|
||||
val contentRatings: Set<ContentRating>
|
||||
|
||||
var defaultSortOrder: SortOrder
|
||||
|
||||
val isMultipleTagsSupported: Boolean
|
||||
val filterCapabilities: MangaListFilterCapabilities
|
||||
|
||||
val isTagsExclusionSupported: Boolean
|
||||
|
||||
val isSearchSupported: Boolean
|
||||
|
||||
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
|
||||
suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga>
|
||||
|
||||
suspend fun getDetails(manga: Manga): Manga
|
||||
|
||||
@@ -55,14 +45,12 @@ interface MangaRepository {
|
||||
|
||||
suspend fun getPageUrl(page: MangaPage): String
|
||||
|
||||
suspend fun getTags(): Set<MangaTag>
|
||||
|
||||
suspend fun getLocales(): Set<Locale>
|
||||
suspend fun getFilterOptions(): MangaListFilterOptions
|
||||
|
||||
suspend fun getRelated(seed: Manga): List<Manga>
|
||||
|
||||
suspend fun find(manga: Manga): Manga? {
|
||||
val list = getList(0, MangaListFilter.Search(manga.title))
|
||||
val list = getList(0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||
return list.find { x -> x.id == manga.id }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import okhttp3.Headers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
@@ -9,19 +9,18 @@ import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Favicons
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.domain
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.Locale
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
|
||||
class ParserMangaRepository(
|
||||
private val parser: MangaParser,
|
||||
@@ -29,17 +28,20 @@ class ParserMangaRepository(
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache), Interceptor {
|
||||
|
||||
private val filterOptionsLazy = suspendLazy(Dispatchers.Default) {
|
||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getFilterOptions()
|
||||
}
|
||||
}
|
||||
|
||||
override val source: MangaParserSource
|
||||
get() = parser.source
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = parser.availableSortOrders
|
||||
|
||||
override val states: Set<MangaState>
|
||||
get() = parser.availableStates
|
||||
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = parser.availableContentRating
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = parser.filterCapabilities
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = getConfig().defaultSortOrder ?: sortOrders.first()
|
||||
@@ -47,15 +49,6 @@ class ParserMangaRepository(
|
||||
getConfig().defaultSortOrder = value
|
||||
}
|
||||
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = parser.isMultipleTagsSupported
|
||||
|
||||
override val isSearchSupported: Boolean
|
||||
get() = parser.isSearchSupported
|
||||
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = parser.isTagsExclusionSupported
|
||||
|
||||
var domain: String
|
||||
get() = parser.domain
|
||||
set(value) {
|
||||
@@ -65,9 +58,6 @@ class ParserMangaRepository(
|
||||
val domains: Array<out String>
|
||||
get() = parser.configKeyDomain.presetValues
|
||||
|
||||
val headers: Headers
|
||||
get() = parser.headers
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return if (parser is Interceptor) {
|
||||
parser.intercept(chain)
|
||||
@@ -76,9 +66,9 @@ class ParserMangaRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
|
||||
return mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getList(offset, filter)
|
||||
parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,16 +79,12 @@ 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 getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getAvailableTags()
|
||||
}
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> {
|
||||
return parser.getAvailableLocales()
|
||||
}
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
|
||||
|
||||
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getFavicons()
|
||||
@@ -112,6 +98,8 @@ class ParserMangaRepository(
|
||||
|
||||
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
|
||||
|
||||
fun getRequestHeaders() = parser.getRequestHeaders()
|
||||
|
||||
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {
|
||||
parser.onCreateConfig(it)
|
||||
}
|
||||
|
||||
@@ -1,264 +1,67 @@
|
||||
package org.koitharu.kotatsu.core.parser.external
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
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.splitTwoParts
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class ExternalMangaRepository(
|
||||
private val contentResolver: ContentResolver,
|
||||
contentResolver: ContentResolver,
|
||||
override val source: ExternalMangaSource,
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache) {
|
||||
|
||||
private val capabilities by lazy { queryCapabilities() }
|
||||
private val contentSource = ExternalPluginContentSource(contentResolver, source)
|
||||
|
||||
private val capabilities by lazy {
|
||||
runCatching {
|
||||
contentSource.getCapabilities()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private val filterOptions = suspendLazy(initializer = contentSource::getListFilterOptions)
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
|
||||
override val states: Set<MangaState>
|
||||
get() = capabilities?.availableStates.orEmpty()
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = capabilities?.availableContentRating.orEmpty()
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)
|
||||
|
||||
override val filterCapabilities: MangaListFilterCapabilities
|
||||
get() = capabilities?.listFilterCapabilities ?: MangaListFilterCapabilities()
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
|
||||
set(value) = Unit
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = capabilities?.isMultipleTagsSupported ?: true
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = capabilities?.isTagsExclusionSupported ?: false
|
||||
override val isSearchSupported: Boolean
|
||||
get() = capabilities?.isSearchSupported ?: true
|
||||
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
|
||||
set(_) = Unit
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
||||
uri.appendQueryParameter("offset", offset.toString())
|
||||
when (filter) {
|
||||
is MangaListFilter.Advanced -> {
|
||||
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
|
||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
|
||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||
}
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()
|
||||
|
||||
is MangaListFilter.Search -> {
|
||||
uri.appendQueryParameter("query", filter.query)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)?.use { cursor ->
|
||||
val result = ArrayList<Manga>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += cursor.getManga()
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> =
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
|
||||
}
|
||||
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = coroutineScope {
|
||||
val chapters = async { queryChapters(manga.url) }
|
||||
val details = queryDetails(manga.url)
|
||||
Manga(
|
||||
id = manga.id,
|
||||
title = details.title.ifBlank { manga.title },
|
||||
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
|
||||
url = details.url.ifEmpty { manga.url },
|
||||
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
|
||||
rating = maxOf(details.rating, manga.rating),
|
||||
isNsfw = details.isNsfw,
|
||||
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
|
||||
tags = details.tags + manga.tags,
|
||||
state = details.state ?: manga.state,
|
||||
author = details.author.ifNullOrEmpty { manga.author },
|
||||
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
description = details.description.ifNullOrEmpty { manga.description },
|
||||
chapters = chapters.await(),
|
||||
source = source,
|
||||
)
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getDetails(manga)
|
||||
}
|
||||
|
||||
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(chapter.url)
|
||||
.build()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArrayList<MangaPage>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaPage(
|
||||
id = cursor.getLong(0),
|
||||
url = cursor.getString(1),
|
||||
preview = cursor.getStringOrNull(2),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getPages(chapter)
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = page.url
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/tags".toUri()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArraySet<MangaTag>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaTag(
|
||||
key = cursor.getString(0),
|
||||
title = cursor.getString(1),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
override suspend fun getPageUrl(page: MangaPage): String = runInterruptible(Dispatchers.IO) {
|
||||
contentSource.getPageUrl(page.url)
|
||||
}
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = emptySet()
|
||||
|
||||
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
|
||||
|
||||
private suspend fun queryDetails(url: String): Manga = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
checkNotNull(
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getManga()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun queryChapters(url: String): List<MangaChapter>? = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArrayList<MangaChapter>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaChapter(
|
||||
id = cursor.getLong(0),
|
||||
name = cursor.getString(1),
|
||||
number = cursor.getFloat(2),
|
||||
volume = cursor.getInt(3),
|
||||
url = cursor.getString(4),
|
||||
scanlator = cursor.getStringOrNull(5),
|
||||
uploadDate = cursor.getLong(6),
|
||||
branch = cursor.getStringOrNull(7),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor.getManga() = Manga(
|
||||
id = getLong(0),
|
||||
title = getString(1),
|
||||
altTitle = getStringOrNull(2),
|
||||
url = getString(3),
|
||||
publicUrl = getString(4),
|
||||
rating = getFloat(5),
|
||||
isNsfw = getInt(6) > 1,
|
||||
coverUrl = getString(7),
|
||||
tags = getStringOrNull(8)?.split(':')?.mapNotNullToSet {
|
||||
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
|
||||
MangaTag(key = parts.first, title = parts.second, source = source)
|
||||
}.orEmpty(),
|
||||
state = getStringOrNull(9)?.let { MangaState.entries.find(it) },
|
||||
author = optString(10),
|
||||
largeCoverUrl = optString(11),
|
||||
description = optString(12),
|
||||
chapters = emptyList(),
|
||||
source = source,
|
||||
)
|
||||
|
||||
private fun Cursor.optString(columnIndex: Int): String? {
|
||||
return if (isNull(columnIndex)) {
|
||||
null
|
||||
} else {
|
||||
getString(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryCapabilities(): MangaSourceCapabilities? {
|
||||
val uri = "content://${source.authority}/capabilities".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
MangaSourceCapabilities(
|
||||
availableSortOrders = cursor.getStringOrNull(0)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
|
||||
SortOrder.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableStates = cursor.getStringOrNull(1)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
|
||||
MangaState.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableContentRating = cursor.getStringOrNull(2)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
|
||||
ContentRating.entries.find(it)
|
||||
}.orEmpty(),
|
||||
isMultipleTagsSupported = cursor.getInt(3) > 1,
|
||||
isTagsExclusionSupported = cursor.getInt(4) > 1,
|
||||
isSearchSupported = cursor.getInt(5) > 1,
|
||||
contentType = ContentType.entries.find(cursor.getString(6)) ?: ContentType.OTHER,
|
||||
defaultSortOrder = cursor.getStringOrNull(7)?.let {
|
||||
SortOrder.entries.find(it)
|
||||
} ?: SortOrder.ALPHABETICAL,
|
||||
sourceLocale = cursor.getStringOrNull(8)?.let { Locale(it) } ?: Locale.ROOT,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MangaSourceCapabilities(
|
||||
val availableSortOrders: Set<SortOrder>,
|
||||
val availableStates: Set<MangaState>,
|
||||
val availableContentRating: Set<ContentRating>,
|
||||
val isMultipleTagsSupported: Boolean,
|
||||
val isTagsExclusionSupported: Boolean,
|
||||
val isSearchSupported: Boolean,
|
||||
val contentType: ContentType,
|
||||
val defaultSortOrder: SortOrder,
|
||||
val sourceLocale: Locale,
|
||||
)
|
||||
}
|
||||
|
||||
332
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt
vendored
Normal file
332
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt
vendored
Normal file
@@ -0,0 +1,332 @@
|
||||
package org.koitharu.kotatsu.core.parser.external
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.ArraySet
|
||||
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
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
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.splitTwoParts
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class ExternalPluginContentSource(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val source: ExternalMangaSource,
|
||||
) {
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getListFilterOptions() = MangaListFilterOptions(
|
||||
availableTags = fetchTags(),
|
||||
availableStates = fetchEnumSet(MangaState::class.java, "filter/states"),
|
||||
availableContentRating = fetchEnumSet(ContentRating::class.java, "filter/content_ratings"),
|
||||
availableContentTypes = fetchEnumSet(ContentType::class.java, "filter/content_types"),
|
||||
availableDemographics = fetchEnumSet(Demographic::class.java, "filter/demographics"),
|
||||
availableLocales = fetchLocales(),
|
||||
)
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
||||
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
||||
uri.appendQueryParameter("offset", offset.toString())
|
||||
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
|
||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
|
||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||
if (!filter.query.isNullOrEmpty()) {
|
||||
uri.appendQueryParameter("query", filter.query)
|
||||
}
|
||||
return contentResolver.query(uri.build(), null, null, null, order.name)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArrayList<Manga>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += cursor.getManga()
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getDetails(manga: Manga): Manga {
|
||||
val chapters = queryChapters(manga.url)
|
||||
val details = queryDetails(manga.url)
|
||||
return Manga(
|
||||
id = manga.id,
|
||||
title = details.title.ifBlank { manga.title },
|
||||
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
|
||||
url = details.url.ifEmpty { manga.url },
|
||||
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
|
||||
rating = maxOf(details.rating, manga.rating),
|
||||
isNsfw = details.isNsfw,
|
||||
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
|
||||
tags = details.tags + manga.tags,
|
||||
state = details.state ?: manga.state,
|
||||
author = details.author.ifNullOrEmpty { manga.author },
|
||||
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
description = details.description.ifNullOrEmpty { manga.description },
|
||||
chapters = chapters,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val uri = "content://${source.authority}/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(chapter.url)
|
||||
.build()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArrayList<MangaPage>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaPage(
|
||||
id = cursor.getLong(COLUMN_ID),
|
||||
url = cursor.getString(COLUMN_URL),
|
||||
preview = cursor.getStringOrNull(COLUMN_PREVIEW),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
private fun fetchTags(): Set<MangaTag> {
|
||||
val uri = "content://${source.authority}/filter/tags".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArraySet<MangaTag>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaTag(
|
||||
key = cursor.getString(COLUMN_KEY),
|
||||
title = cursor.getString(COLUMN_TITLE),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun getPageUrl(url: String): String {
|
||||
val uri = "content://${source.authority}/manga/pages/0".toUri().buildUpon()
|
||||
.appendQueryParameter("url", url)
|
||||
.build()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getString(COLUMN_VALUE)
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
private fun fetchLocales(): Set<Locale> {
|
||||
val uri = "content://${source.authority}/filter/locales".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArraySet<Locale>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += Locale(cursor.getString(COLUMN_NAME))
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fun getCapabilities(): MangaSourceCapabilities? {
|
||||
val uri = "content://${source.authority}/capabilities".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
MangaSourceCapabilities(
|
||||
availableSortOrders = cursor.getStringOrNull(COLUMN_SORT_ORDERS)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
|
||||
SortOrder.entries.find(it)
|
||||
}.orEmpty(),
|
||||
listFilterCapabilities = MangaListFilterCapabilities(
|
||||
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS, false),
|
||||
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION, false),
|
||||
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH, false),
|
||||
isSearchWithFiltersSupported = cursor.getBooleanOrDefault(
|
||||
COLUMN_SEARCH_WITH_FILTERS,
|
||||
false,
|
||||
),
|
||||
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
|
||||
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
|
||||
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryDetails(url: String): Manga {
|
||||
val uri = "content://${source.authority}/manga".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getManga()
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryChapters(url: String): List<MangaChapter> {
|
||||
val uri = "content://${source.authority}/manga/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = ArrayList<MangaChapter>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaChapter(
|
||||
id = cursor.getLong(COLUMN_ID),
|
||||
name = cursor.getString(COLUMN_NAME),
|
||||
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
|
||||
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
|
||||
url = cursor.getString(COLUMN_URL),
|
||||
scanlator = cursor.getStringOrNull(COLUMN_SCANLATOR),
|
||||
uploadDate = cursor.getLongOrDefault(COLUMN_UPLOAD_DATE, 0L),
|
||||
branch = cursor.getStringOrNull(COLUMN_BRANCH),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun ExternalPluginCursor.getManga() = Manga(
|
||||
id = getLong(COLUMN_ID),
|
||||
title = getString(COLUMN_TITLE),
|
||||
altTitle = getStringOrNull(COLUMN_ALT_TITLE),
|
||||
url = getString(COLUMN_URL),
|
||||
publicUrl = getString(COLUMN_PUBLIC_URL),
|
||||
rating = getFloat(COLUMN_RATING),
|
||||
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
|
||||
coverUrl = getString(COLUMN_COVER_URL),
|
||||
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
|
||||
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
|
||||
MangaTag(key = parts.first, title = parts.second, source = source)
|
||||
}.orEmpty(),
|
||||
state = getStringOrNull(COLUMN_STATE)?.let { MangaState.entries.find(it) },
|
||||
author = getStringOrNull(COLUMN_AUTHOR),
|
||||
largeCoverUrl = getStringOrNull(COLUMN_LARGE_COVER_URL),
|
||||
description = getStringOrNull(COLUMN_DESCRIPTION),
|
||||
chapters = emptyList(),
|
||||
source = source,
|
||||
)
|
||||
|
||||
private fun <E : Enum<E>> fetchEnumSet(cls: Class<E>, path: String): EnumSet<E> {
|
||||
val uri = "content://${source.authority}/$path".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)
|
||||
.safe()
|
||||
.use { cursor ->
|
||||
val result = EnumSet.noneOf(cls)
|
||||
val enumConstants = cls.enumConstants ?: return@use result
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
val name = cursor.getString(COLUMN_NAME)
|
||||
val enumValue = enumConstants.find { it.name == name }
|
||||
if (enumValue != null) {
|
||||
result.add(enumValue)
|
||||
}
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor?.safe() = ExternalPluginCursor(
|
||||
source = source,
|
||||
cursor = this ?: throw IncompatiblePluginException(source.name, null),
|
||||
)
|
||||
|
||||
class MangaSourceCapabilities(
|
||||
val availableSortOrders: Set<SortOrder>,
|
||||
val listFilterCapabilities: MangaListFilterCapabilities,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
|
||||
const val COLUMN_SORT_ORDERS = "sort_orders"
|
||||
const val COLUMN_MULTIPLE_TAGS = "multiple_tags"
|
||||
const val COLUMN_TAGS_EXCLUSION = "tags_exclusion"
|
||||
const val COLUMN_SEARCH = "search"
|
||||
const val COLUMN_SEARCH_WITH_FILTERS = "search_with_filters"
|
||||
const val COLUMN_YEAR = "year"
|
||||
const val COLUMN_YEAR_RANGE = "year_range"
|
||||
const val COLUMN_ORIGINAL_LOCALE = "original_locale"
|
||||
const val COLUMN_ID = "id"
|
||||
const val COLUMN_NAME = "name"
|
||||
const val COLUMN_NUMBER = "number"
|
||||
const val COLUMN_VOLUME = "volume"
|
||||
const val COLUMN_URL = "url"
|
||||
const val COLUMN_SCANLATOR = "scanlator"
|
||||
const val COLUMN_UPLOAD_DATE = "upload_date"
|
||||
const val COLUMN_BRANCH = "branch"
|
||||
const val COLUMN_TITLE = "title"
|
||||
const val COLUMN_ALT_TITLE = "alt_title"
|
||||
const val COLUMN_PUBLIC_URL = "public_url"
|
||||
const val COLUMN_RATING = "rating"
|
||||
const val COLUMN_IS_NSFW = "is_nsfw"
|
||||
const val COLUMN_COVER_URL = "cover_url"
|
||||
const val COLUMN_TAGS = "tags"
|
||||
const val COLUMN_STATE = "state"
|
||||
const val COLUMN_AUTHOR = "author"
|
||||
const val COLUMN_LARGE_COVER_URL = "large_cover_url"
|
||||
const val COLUMN_DESCRIPTION = "description"
|
||||
const val COLUMN_PREVIEW = "preview"
|
||||
const val COLUMN_KEY = "key"
|
||||
const val COLUMN_VALUE = "value"
|
||||
}
|
||||
}
|
||||
70
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginCursor.kt
vendored
Normal file
70
app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginCursor.kt
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
package org.koitharu.kotatsu.core.parser.external
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.CursorWrapper
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.util.ext.getBoolean
|
||||
|
||||
class ExternalPluginCursor(private val source: ExternalMangaSource, cursor: Cursor) : CursorWrapper(cursor) {
|
||||
|
||||
override fun getColumnIndexOrThrow(columnName: String?): Int = try {
|
||||
super.getColumnIndexOrThrow(columnName)
|
||||
} catch (e: Exception) {
|
||||
throw IncompatiblePluginException(source.name, e)
|
||||
}
|
||||
|
||||
fun getString(columnName: String): String = getString(getColumnIndexOrThrow(columnName))
|
||||
|
||||
fun getStringOrNull(columnName: String): String? {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> null
|
||||
isNull(columnIndex) -> null
|
||||
else -> getString(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBoolean(columnName: String): Boolean = getBoolean(getColumnIndexOrThrow(columnName))
|
||||
|
||||
fun getBooleanOrDefault(columnName: String, defaultValue: Boolean): Boolean {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> defaultValue
|
||||
isNull(columnIndex) -> defaultValue
|
||||
else -> getBoolean(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getInt(columnName: String): Int = getInt(getColumnIndexOrThrow(columnName))
|
||||
|
||||
fun getIntOrDefault(columnName: String, defaultValue: Int): Int {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> defaultValue
|
||||
isNull(columnIndex) -> defaultValue
|
||||
else -> getInt(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLong(columnName: String): Long = getLong(getColumnIndexOrThrow(columnName))
|
||||
|
||||
fun getLongOrDefault(columnName: String, defaultValue: Long): Long {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> defaultValue
|
||||
isNull(columnIndex) -> defaultValue
|
||||
else -> getLong(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFloat(columnName: String): Float = getFloat(getColumnIndexOrThrow(columnName))
|
||||
|
||||
fun getFloatOrDefault(columnName: String, defaultValue: Float): Float {
|
||||
val columnIndex = getColumnIndex(columnName)
|
||||
return when {
|
||||
columnIndex < 0 -> defaultValue
|
||||
isNull(columnIndex) -> defaultValue
|
||||
else -> getFloat(columnIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user