Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4855b2c160 | ||
|
|
89d395178c | ||
|
|
9942ad5e56 | ||
|
|
d59b0626bc | ||
|
|
63054e55d6 | ||
|
|
486daf69bf | ||
|
|
af209d7048 | ||
|
|
6bf034fd37 |
@@ -2,12 +2,13 @@
|
||||
|
||||
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
|
||||
|
||||
[](https://github.com/KotatsuApp/kotatsu-parsers)  [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
[](https://github.com/KotatsuApp/kotatsu-parsers)   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Download
|
||||
|
||||
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
|
||||
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
|
||||
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
||||
|
||||
### Main Features
|
||||
|
||||
|
||||
165
app/build.gradle
165
app/build.gradle
@@ -1,3 +1,5 @@
|
||||
import java.time.LocalDateTime
|
||||
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
@@ -16,8 +18,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 682
|
||||
versionName = '7.7-a3'
|
||||
versionCode = 692
|
||||
versionName = '7.7'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -37,11 +39,23 @@ android {
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
nightly {
|
||||
initWith release
|
||||
applicationIdSuffix = '.nightly'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += [
|
||||
'META-INF/README.md',
|
||||
'META-INF/NOTICE.md'
|
||||
]
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
main.java.srcDirs += 'src/main/kotlin/'
|
||||
@@ -64,7 +78,7 @@ android {
|
||||
}
|
||||
lint {
|
||||
abortOnError true
|
||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled'
|
||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources true
|
||||
@@ -73,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 {
|
||||
@@ -82,88 +105,92 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:d8cb38a9be') {
|
||||
def parsersVersion = libs.versions.parsers.get()
|
||||
if (System.properties.containsKey('parsersVersionOverride')) {
|
||||
// usage:
|
||||
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
|
||||
parsersVersion = System.getProperty('parsersVersionOverride')
|
||||
}
|
||||
//noinspection UseTomlInstead
|
||||
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
||||
coreLibraryDesugaring libs.desugar.jdk.libs
|
||||
implementation libs.kotlin.stdlib
|
||||
implementation libs.kotlinx.coroutines.android
|
||||
implementation libs.kotlinx.coroutines.guava
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.3'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.8.4'
|
||||
implementation 'androidx.transition:transition-ktx:1.5.1'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.4'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
|
||||
implementation 'androidx.webkit:webkit:1.11.0'
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.androidx.core
|
||||
implementation libs.androidx.activity
|
||||
implementation libs.androidx.fragment
|
||||
implementation libs.androidx.transition
|
||||
implementation libs.androidx.collection
|
||||
implementation libs.lifecycle.viewmodel
|
||||
implementation libs.lifecycle.service
|
||||
implementation libs.lifecycle.process
|
||||
implementation libs.androidx.constraintlayout
|
||||
implementation libs.androidx.swiperefreshlayout
|
||||
implementation libs.androidx.recyclerview
|
||||
implementation libs.androidx.viewpager2
|
||||
implementation libs.androidx.preference
|
||||
implementation libs.androidx.biometric
|
||||
implementation libs.material
|
||||
implementation libs.androidx.lifecycle.common.java8
|
||||
implementation libs.androidx.webkit
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.1'
|
||||
//noinspection GradleDependency
|
||||
implementation('com.google.guava:guava:33.2.1-android') {
|
||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
||||
exclude group: 'org.checkerframework', module: 'checker-qual'
|
||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||
}
|
||||
implementation libs.androidx.work.runtime
|
||||
implementation libs.guava
|
||||
|
||||
implementation 'androidx.room:room-runtime:2.6.1'
|
||||
implementation 'androidx.room:room-ktx:2.6.1'
|
||||
ksp 'androidx.room:room-compiler:2.6.1'
|
||||
implementation libs.androidx.room.runtime
|
||||
implementation libs.androidx.room.ktx
|
||||
ksp libs.androidx.room.compiler
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||
implementation 'com.squareup.okio:okio:3.9.1'
|
||||
implementation libs.okhttp
|
||||
implementation libs.okhttp.tls
|
||||
implementation libs.okhttp.dnsoverhttps
|
||||
implementation libs.okio
|
||||
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
implementation libs.adapterdelegates
|
||||
implementation libs.adapterdelegates.viewbinding
|
||||
|
||||
implementation 'com.google.dagger:hilt-android:2.52'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.52'
|
||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
||||
implementation libs.hilt.android
|
||||
kapt libs.hilt.compiler
|
||||
implementation libs.androidx.hilt.work
|
||||
kapt libs.androidx.hilt.compiler
|
||||
|
||||
implementation 'io.coil-kt.coil3:coil-core:3.0.0-rc01'
|
||||
implementation 'io.coil-kt.coil3:coil-network-okhttp:3.0.0-rc01'
|
||||
implementation 'io.coil-kt.coil3:coil-gif:3.0.0-rc01'
|
||||
implementation 'org.aomedia.avif.android:avif:1.1.1.14d8e3c4'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d1d10a6975'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
implementation libs.coil.core
|
||||
implementation libs.coil.network
|
||||
implementation libs.coil.gif
|
||||
implementation libs.coil.svg
|
||||
implementation libs.avif.decoder
|
||||
implementation libs.ssiv
|
||||
implementation libs.disk.lru.cache
|
||||
implementation libs.markwon
|
||||
|
||||
implementation 'ch.acra:acra-http:5.11.4'
|
||||
implementation 'ch.acra:acra-dialog:5.11.4'
|
||||
implementation libs.acra.http
|
||||
implementation libs.acra.dialog
|
||||
|
||||
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
||||
implementation libs.conscrypt.android
|
||||
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8'
|
||||
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
||||
debugImplementation libs.leakcanary.android
|
||||
debugImplementation libs.workinspector
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.json
|
||||
testImplementation libs.kotlinx.coroutines.test
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.6.1'
|
||||
androidTestImplementation 'androidx.test:rules:1.6.1'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
||||
androidTestImplementation libs.androidx.runner
|
||||
androidTestImplementation libs.androidx.rules
|
||||
androidTestImplementation libs.androidx.test.core
|
||||
androidTestImplementation libs.androidx.junit
|
||||
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
|
||||
androidTestImplementation libs.kotlinx.coroutines.test
|
||||
|
||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||
androidTestImplementation libs.androidx.room.testing
|
||||
androidTestImplementation libs.moshi.kotlin
|
||||
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52'
|
||||
androidTestImplementation libs.hilt.android.testing
|
||||
kaptAndroidTest libs.hilt.android.compiler
|
||||
}
|
||||
|
||||
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
@@ -15,6 +15,7 @@
|
||||
-dontwarn org.bouncycastle.**
|
||||
-dontwarn org.openjsse.**
|
||||
-dontwarn com.google.j2objc.annotations.**
|
||||
-dontwarn coil3.PlatformContext
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||
@@ -26,3 +27,4 @@
|
||||
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
||||
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
||||
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
||||
-keep class org.acra.sender.JobSenderService
|
||||
|
||||
@@ -9,11 +9,12 @@ import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.os.strictmode.Violation
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import kotlin.math.absoluteValue
|
||||
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||
|
||||
@@ -42,7 +43,7 @@ class StrictModeNotifier(
|
||||
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||
|
||||
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setSmallIcon(R.drawable.ic_bug)
|
||||
.setContentTitle(context.getString(R.string.strict_mode))
|
||||
.setContentText(violation.message)
|
||||
.setStyle(
|
||||
@@ -51,7 +52,15 @@ class StrictModeNotifier(
|
||||
.setSummaryText(violation.message)
|
||||
.bigText(violation.stackTraceToString()),
|
||||
).setShowWhen(true)
|
||||
.setContentIntent(ErrorReporterReceiver.getPendingIntent(context, violation))
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
0,
|
||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
.setAutoCancel(true)
|
||||
.setGroup(CHANNEL_ID)
|
||||
.build()
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
||||
@@ -12,8 +13,11 @@ class CurlLoggingInterceptor(
|
||||
|
||||
private val escapeRegex = Regex("([\\[\\]\"])")
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
|
||||
logRequest(it.networkResponse?.request ?: it.request)
|
||||
}
|
||||
|
||||
private fun logRequest(request: Request) {
|
||||
var isCompressed = false
|
||||
|
||||
val curlCmd = StringBuilder()
|
||||
@@ -46,16 +50,11 @@ class CurlLoggingInterceptor(
|
||||
|
||||
log("---cURL (" + request.url + ")")
|
||||
log(curlCmd.toString())
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun String.escape() = replace(escapeRegex) { match ->
|
||||
"\\" + match.value
|
||||
}
|
||||
// .replace("\"", "\\\"")
|
||||
// .replace("[", "\\[")
|
||||
// .replace("]", "\\]")
|
||||
|
||||
private fun log(msg: String) {
|
||||
Log.d("CURL", msg)
|
||||
|
||||
15
app/src/debug/res/drawable-anydpi-v24/ic_bug.xml
Normal file
15
app/src/debug/res/drawable-anydpi-v24/ic_bug.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="0.98150784"
|
||||
android:scaleY="0.98150784"
|
||||
android:translateX="0.22190611"
|
||||
android:translateY="-0.2688478">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
||||
</group>
|
||||
</vector>
|
||||
BIN
app/src/debug/res/drawable-hdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-hdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
BIN
app/src/debug/res/drawable-mdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-mdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 B |
BIN
app/src/debug/res/drawable-xhdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-xhdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 480 B |
BIN
app/src/debug/res/drawable-xxhdpi/ic_bug.png
Normal file
BIN
app/src/debug/res/drawable-xxhdpi/ic_bug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 792 B |
@@ -266,19 +266,26 @@
|
||||
tools:node="merge" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="@string/local_manga_processing" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="@string/periodic_backups" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" />
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:label="@string/fixing_manga" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
|
||||
android:label="@string/local_manga_processing" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||
android:label="@string/manga_shelf"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
|
||||
android:label="@string/recent_manga"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
|
||||
@@ -315,7 +322,8 @@
|
||||
</service>
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:label="@string/prefetch_content" />
|
||||
|
||||
<provider
|
||||
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
|
||||
@@ -394,7 +402,7 @@
|
||||
android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
|
||||
|
||||
<activity-alias
|
||||
android:name="org.koitharu.kotatsu.details.ui.DetailsBYLinkActivity"
|
||||
android:name="org.koitharu.kotatsu.details.ui.DetailsByLinkActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity="org.koitharu.kotatsu.details.ui.DetailsActivity">
|
||||
|
||||
|
||||
@@ -48,25 +48,21 @@ class AutoFixService : CoroutineIntentService() {
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
}
|
||||
|
||||
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||
startForeground(startId)
|
||||
try {
|
||||
for (mangaId in ids) {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
startForeground(this)
|
||||
for (mangaId in ids) {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
} finally {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) {
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
@@ -74,7 +70,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground(startId: Int) {
|
||||
private fun startForeground(jobContext: IntentJobContext) {
|
||||
val title = applicationContext.getString(R.string.fixing_manga)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||
.setName(title)
|
||||
@@ -98,12 +94,11 @@ class AutoFixService : CoroutineIntentService() {
|
||||
.addAction(
|
||||
materialR.drawable.material_ic_clear_black_24dp,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
getCancelIntent(startId),
|
||||
jobContext.getCancelIntent(),
|
||||
)
|
||||
.build()
|
||||
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
jobContext.setForeground(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
|
||||
@@ -29,7 +29,7 @@ class CaptchaNotifier(
|
||||
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)
|
||||
@@ -42,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(
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -126,6 +127,7 @@ interface AppModule {
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
add(CbzFetcher.Factory())
|
||||
add(AvifImageDecoder.Factory())
|
||||
add(FaviconFetcher.Factory(mangaRepositoryFactory))
|
||||
|
||||
@@ -78,6 +78,9 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (ACRA.isACRASenderServiceProcess()) {
|
||||
return
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||
// TLS 1.3 support for Android < 10
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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,13 @@
|
||||
package org.koitharu.kotatsu.core.io
|
||||
|
||||
import java.io.OutputStream
|
||||
import java.util.Objects
|
||||
|
||||
class NullOutputStream : OutputStream() {
|
||||
|
||||
override fun write(b: Int) = Unit
|
||||
|
||||
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||
Objects.checkFromIndexSize(off, len, b.size)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.util.formatSimple
|
||||
import org.koitharu.kotatsu.parsers.util.findById
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -29,8 +29,6 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||
@JvmName("chaptersIds")
|
||||
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
|
||||
|
||||
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
|
||||
|
||||
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
|
||||
if (size <= 1) {
|
||||
return size
|
||||
@@ -84,10 +82,6 @@ val Demographic.titleResId: Int
|
||||
Demographic.NONE -> R.string.none
|
||||
}
|
||||
|
||||
fun Manga.findChapter(id: Long): MangaChapter? {
|
||||
return chapters?.findById(id)
|
||||
}
|
||||
|
||||
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
||||
val ch = chapters
|
||||
if (ch.isNullOrEmpty()) {
|
||||
@@ -136,12 +130,6 @@ val Manga.appUrl: Uri
|
||||
.appendQueryParameter("url", url)
|
||||
.build()
|
||||
|
||||
fun MangaChapter.formatNumber(): String? = if (number > 0f) {
|
||||
number.formatSimple()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun Manga.chaptersCount(): Int {
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return 0
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
@@ -17,9 +18,9 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.domain
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
|
||||
class ParserMangaRepository(
|
||||
private val parser: MangaParser,
|
||||
@@ -27,7 +28,7 @@ class ParserMangaRepository(
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache), Interceptor {
|
||||
|
||||
private val filterOptionsLazy = SuspendLazy {
|
||||
private val filterOptionsLazy = suspendLazy(Dispatchers.Default) {
|
||||
mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getFilterOptions()
|
||||
}
|
||||
@@ -78,7 +79,9 @@ class ParserMangaRepository(
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||
parser.getPageUrl(page)
|
||||
parser.getPageUrl(page).also { result ->
|
||||
check(result.isNotEmpty()) { "Page url is empty" }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import java.util.EnumSet
|
||||
|
||||
class ExternalMangaRepository(
|
||||
@@ -32,7 +32,7 @@ class ExternalMangaRepository(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private val filterOptions = SuspendLazy(contentSource::getListFilterOptions)
|
||||
private val filterOptions = suspendLazy(initializer = contentSource::getListFilterOptions)
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)
|
||||
@@ -42,7 +42,7 @@ class ExternalMangaRepository(
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
|
||||
set(value) = Unit
|
||||
set(_) = Unit
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.core.net.toUri
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Demographic
|
||||
|
||||
@@ -19,6 +19,7 @@ import coil3.toAndroidUri
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
|
||||
@@ -26,6 +27,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.fetch
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import coil3.Uri as CoilUri
|
||||
|
||||
@@ -36,7 +38,7 @@ class FaviconFetcher(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val mangaSource = MangaSource(uri.schemeSpecificPart)
|
||||
|
||||
return when (val repo = mangaRepositoryFactory.create(mangaSource)) {
|
||||
@@ -48,7 +50,9 @@ class FaviconFetcher(
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("")
|
||||
is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options)
|
||||
|
||||
else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
import java.io.File
|
||||
import java.net.Proxy
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -473,7 +474,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val periodicalBackupFrequency: Long
|
||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
|
||||
|
||||
var periodicalBackupOutput: Uri?
|
||||
val periodicalBackupFrequencyMillis: Long
|
||||
get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
|
||||
|
||||
val periodicalBackupMaxCount: Int
|
||||
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
|
||||
prefs.getInt(KEY_BACKUP_PERIODICAL_COUNT, 10)
|
||||
} else {
|
||||
Int.MAX_VALUE
|
||||
}
|
||||
|
||||
var periodicalBackupDirectory: Uri?
|
||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
|
||||
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
|
||||
|
||||
@@ -621,6 +632,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_RESTORE = "restore"
|
||||
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
|
||||
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
|
||||
const val KEY_BACKUP_PERIODICAL_TRIM = "backup_periodic_trim"
|
||||
const val KEY_BACKUP_PERIODICAL_COUNT = "backup_periodic_count"
|
||||
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
|
||||
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
|
||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||
@@ -714,6 +727,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_LINK_MANUAL = "about_help"
|
||||
const val KEY_PROXY_TEST = "proxy_test"
|
||||
const val KEY_OPEN_BROWSER = "open_browser"
|
||||
const val KEY_HANDLE_LINKS = "handle_links"
|
||||
|
||||
// old keys are for migration only
|
||||
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||
|
||||
@@ -112,9 +112,13 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
ActivityCompat.recreate(this)
|
||||
return true
|
||||
if (BuildConfig.DEBUG) {
|
||||
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
|
||||
ActivityCompat.recreate(this)
|
||||
return true
|
||||
} else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
|
||||
throw RuntimeException("Test crash")
|
||||
}
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -9,11 +10,10 @@ import android.os.PatternMatcher
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -21,60 +21,111 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
abstract class CoroutineIntentService : BaseService() {
|
||||
|
||||
private val mutex = Mutex()
|
||||
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
val job = launchCoroutine(intent, startId)
|
||||
val receiver = CancelReceiver(job)
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
receiver,
|
||||
createIntentFilter(this, startId),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
job.invokeOnCompletion { unregisterReceiver(receiver) }
|
||||
launchCoroutine(intent, startId)
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) {
|
||||
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
|
||||
val intentJobContext = IntentJobContextImpl(startId, coroutineContext)
|
||||
mutex.withLock {
|
||||
try {
|
||||
if (intent != null) {
|
||||
withContext(dispatcher) {
|
||||
processIntent(startId, intent)
|
||||
withContext(Dispatchers.Default) {
|
||||
intentJobContext.processIntent(intent)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
onError(startId, e)
|
||||
intentJobContext.onError(e)
|
||||
} finally {
|
||||
stopSelf(startId)
|
||||
intentJobContext.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
protected abstract suspend fun processIntent(startId: Int, intent: Intent)
|
||||
protected abstract suspend fun IntentJobContext.processIntent(intent: Intent)
|
||||
|
||||
@AnyThread
|
||||
protected abstract fun onError(startId: Int, error: Throwable)
|
||||
protected abstract fun IntentJobContext.onError(error: Throwable)
|
||||
|
||||
protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
createCancelIntent(this, startId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
)
|
||||
interface IntentJobContext {
|
||||
|
||||
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
|
||||
throwable.printStackTraceDebug()
|
||||
onError(startId, throwable)
|
||||
val startId: Int
|
||||
|
||||
fun getCancelIntent(): PendingIntent?
|
||||
|
||||
fun setForeground(id: Int, notification: Notification, serviceType: Int)
|
||||
}
|
||||
|
||||
protected inner class IntentJobContextImpl(
|
||||
override val startId: Int,
|
||||
private val coroutineContext: CoroutineContext,
|
||||
) : IntentJobContext {
|
||||
|
||||
private var cancelReceiver: CancelReceiver? = null
|
||||
private var isStopped = false
|
||||
private var isForeground = false
|
||||
|
||||
override fun getCancelIntent(): PendingIntent? {
|
||||
ensureHasCancelReceiver()
|
||||
return PendingIntentCompat.getBroadcast(
|
||||
applicationContext,
|
||||
0,
|
||||
createCancelIntent(this@CoroutineIntentService, startId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
override fun setForeground(id: Int, notification: Notification, serviceType: Int) {
|
||||
ServiceCompat.startForeground(this@CoroutineIntentService, id, notification, serviceType)
|
||||
isForeground = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(this) {
|
||||
cancelReceiver?.let {
|
||||
try {
|
||||
unregisterReceiver(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
isStopped = true
|
||||
}
|
||||
if (isForeground) {
|
||||
ServiceCompat.stopForeground(this@CoroutineIntentService, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
stopSelf(startId)
|
||||
}
|
||||
|
||||
private fun ensureHasCancelReceiver() {
|
||||
if (cancelReceiver == null && !isStopped) {
|
||||
synchronized(this) {
|
||||
if (cancelReceiver == null && !isStopped) {
|
||||
val job = coroutineContext[Job] ?: return
|
||||
CancelReceiver(job).let { receiver ->
|
||||
ContextCompat.registerReceiver(
|
||||
applicationContext,
|
||||
receiver,
|
||||
createIntentFilter(this@CoroutineIntentService, startId),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
cancelReceiver = receiver
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CancelReceiver(
|
||||
|
||||
@@ -58,7 +58,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
|
||||
if (exception.isReportable()) {
|
||||
builder.setPositiveButton(R.string.report) { _, _ ->
|
||||
dismiss()
|
||||
exception.report()
|
||||
exception.report(silent = true)
|
||||
}
|
||||
}
|
||||
return builder
|
||||
|
||||
@@ -18,9 +18,8 @@ abstract class LifecycleAwareViewHolder(
|
||||
private var isCurrent = false
|
||||
|
||||
init {
|
||||
parentLifecycleOwner.lifecycle.addObserver(ParentLifecycleObserver())
|
||||
if (parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
|
||||
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
|
||||
itemView.post {
|
||||
parentLifecycleOwner.lifecycle.addObserver(ParentLifecycleObserver())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +28,9 @@ abstract class LifecycleAwareViewHolder(
|
||||
dispatchResumed()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onCreate() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
|
||||
|
||||
@CallSuper
|
||||
open fun onStart() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)
|
||||
|
||||
@@ -41,6 +43,9 @@ abstract class LifecycleAwareViewHolder(
|
||||
@CallSuper
|
||||
open fun onStop() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
|
||||
|
||||
@CallSuper
|
||||
open fun onDestroy() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
|
||||
|
||||
private fun dispatchResumed() {
|
||||
val isParentResumed = parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||
if (isCurrent && isParentResumed) {
|
||||
@@ -60,28 +65,18 @@ abstract class LifecycleAwareViewHolder(
|
||||
|
||||
private inner class ParentLifecycleObserver : DefaultLifecycleObserver {
|
||||
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
|
||||
}
|
||||
override fun onCreate(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.onCreate()
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
onStart()
|
||||
}
|
||||
override fun onStart(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.onStart()
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
dispatchResumed()
|
||||
}
|
||||
override fun onResume(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.dispatchResumed()
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
dispatchResumed()
|
||||
}
|
||||
override fun onPause(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.dispatchResumed()
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
onStop()
|
||||
}
|
||||
override fun onStop(owner: LifecycleOwner) = this@LifecycleAwareViewHolder.onStop()
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
|
||||
this@LifecycleAwareViewHolder.onDestroy()
|
||||
owner.lifecycle.removeObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
@@ -75,11 +76,9 @@ class ShareHelper(private val context: Context) {
|
||||
.startChooser()
|
||||
}
|
||||
|
||||
fun shareText(text: String) {
|
||||
ShareCompat.IntentBuilder(context)
|
||||
.setText(text)
|
||||
.setType(TYPE_TEXT)
|
||||
.setChooserTitle(R.string.share)
|
||||
.startChooser()
|
||||
}
|
||||
fun getShareTextIntent(text: String): Intent = ShareCompat.IntentBuilder(context)
|
||||
.setText(text)
|
||||
.setType(TYPE_TEXT)
|
||||
.setChooserTitle(R.string.share)
|
||||
.createChooserIntent()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T?
|
||||
return BundleCompat.getParcelable(this, key, T::class.java)
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T = checkNotNull(getParcelableCompat(key)) {
|
||||
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
|
||||
return IntentCompat.getParcelableExtra(this, key, T::class.java)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@@ -133,4 +134,4 @@ suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x !
|
||||
|
||||
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
|
||||
|
||||
fun <T> SuspendLazy<T>.asFlow() = flow { emit(tryGet()) }
|
||||
fun <T> SuspendLazy<T>.asFlow() = flow { emit(runCatchingCancellable { get() }) }
|
||||
|
||||
@@ -7,6 +7,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.ResponseBody
|
||||
import okio.BufferedSink
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path
|
||||
import okio.Source
|
||||
import org.koitharu.kotatsu.core.util.CancellableSource
|
||||
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
|
||||
@@ -33,3 +36,15 @@ fun InputStream.toByteBuffer(): ByteBuffer {
|
||||
val bytes = outStream.toByteArray()
|
||||
return ByteBuffer.allocateDirect(bytes.size).put(bytes).position(0) as ByteBuffer
|
||||
}
|
||||
|
||||
fun FileSystem.isDirectory(path: Path) = try {
|
||||
metadataOrNull(path)?.isDirectory == true
|
||||
} catch (_: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
fun FileSystem.isRegularFile(path: Path) = try {
|
||||
metadataOrNull(path)?.isRegularFile == true
|
||||
} catch (_: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ fun String.toUUIDOrNull(): UUID? = try {
|
||||
null
|
||||
}
|
||||
|
||||
fun String.digits() = filter { it.isDigit() }
|
||||
|
||||
/**
|
||||
* @param threshold 0 = exact match
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ import okhttp3.Response
|
||||
import okio.FileNotFoundException
|
||||
import okio.IOException
|
||||
import okio.ProtocolException
|
||||
import org.acra.ktx.sendSilentlyWithAcra
|
||||
import org.acra.ktx.sendWithAcra
|
||||
import org.jsoup.HttpStatusException
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -25,6 +26,7 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.io.NullOutputStream
|
||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
|
||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
|
||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
|
||||
@@ -36,20 +38,27 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException
|
||||
import java.io.ObjectOutputStream
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.Locale
|
||||
|
||||
private const val MSG_NO_SPACE_LEFT = "No space left on device"
|
||||
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
||||
|
||||
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
|
||||
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
R.string.scrobbler_auth_required,
|
||||
resources.getString(scrobbler.titleResId),
|
||||
)
|
||||
|
||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
|
||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
|
||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||
is ActivityNotFoundException,
|
||||
is UnsupportedOperationException,
|
||||
@@ -79,16 +88,28 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
is ContentUnavailableException -> message
|
||||
|
||||
is ParseException -> shortMessage
|
||||
is ConnectException,
|
||||
is UnknownHostException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
|
||||
is ImageDecodeException -> resources.getString(
|
||||
R.string.error_image_format,
|
||||
format.ifNullOrEmpty { resources.getString(R.string.unknown) },
|
||||
)
|
||||
is ImageDecodeException -> {
|
||||
val type = format?.substringBefore('/')
|
||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||
if (type.isNullOrEmpty() || type == "image") {
|
||||
resources.getString(R.string.error_image_format, formatString)
|
||||
} else {
|
||||
resources.getString(R.string.error_not_image, formatString)
|
||||
}
|
||||
}
|
||||
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible)
|
||||
is IncompatiblePluginException -> {
|
||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||
} ?: resources.getString(R.string.plugin_incompatible)
|
||||
}
|
||||
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
@@ -97,9 +118,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
|
||||
else -> getDisplayMessage(message, resources) ?: message
|
||||
}.ifNullOrEmpty {
|
||||
resources.getString(R.string.error_occurred)
|
||||
}
|
||||
}.takeUnless { it.isNullOrBlank() }
|
||||
|
||||
@DrawableRes
|
||||
fun Throwable.getDisplayIcon() = when (this) {
|
||||
@@ -107,6 +126,8 @@ fun Throwable.getDisplayIcon() = when (this) {
|
||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is ProtocolException -> R.drawable.ic_plug_large
|
||||
|
||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||
@@ -128,6 +149,7 @@ fun Throwable.getCauseUrl(): String? = when (this) {
|
||||
|
||||
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
|
||||
404 -> resources.getString(R.string.not_found_404)
|
||||
403 -> resources.getString(R.string.access_denied_403)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
}
|
||||
@@ -160,6 +182,8 @@ fun Throwable.isReportable(): Boolean {
|
||||
|| this is CloudFlareProtectedException
|
||||
|| this is BadBackupFormatException
|
||||
|| this is WrongPasswordException
|
||||
|| this is TooManyRequestExceptions
|
||||
|| this is HttpStatusException
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -170,9 +194,13 @@ fun Throwable.isNetworkError(): Boolean {
|
||||
return this is UnknownHostException || this is SocketTimeoutException
|
||||
}
|
||||
|
||||
fun Throwable.report() {
|
||||
val exception = CaughtException(this, "${javaClass.simpleName}($message)")
|
||||
exception.sendWithAcra()
|
||||
fun Throwable.report(silent: Boolean = false) {
|
||||
val exception = CaughtException(this)
|
||||
if (silent) {
|
||||
exception.sendSilentlyWithAcra()
|
||||
} else {
|
||||
exception.sendWithAcra()
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.isWebViewUnavailable(): Boolean {
|
||||
@@ -182,3 +210,9 @@ fun Throwable.isWebViewUnavailable(): Boolean {
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
|
||||
|
||||
fun Throwable.isSerializable() = runCatching {
|
||||
val oos = ObjectOutputStream(NullOutputStream())
|
||||
oos.writeObject(this)
|
||||
oos.flush()
|
||||
}.isSuccess
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import okio.Path
|
||||
import java.io.File
|
||||
|
||||
const val URI_SCHEME_ZIP = "file+zip"
|
||||
@@ -20,6 +22,17 @@ fun Uri.isNetworkUri() = scheme.let {
|
||||
it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS
|
||||
}
|
||||
|
||||
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
|
||||
fun File.toZipUri(entryPath: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryPath")
|
||||
|
||||
fun File.toZipUri(entryPath: Path?): Uri =
|
||||
toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty())
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
fun File.toUri(fragment: String?): Uri = toUri().run {
|
||||
if (fragment != null) {
|
||||
buildUpon().fragment(fragment).build()
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkRequest
|
||||
import androidx.work.await
|
||||
import androidx.work.impl.WorkManagerImpl
|
||||
import androidx.work.impl.model.WorkSpec
|
||||
import kotlinx.coroutines.guava.await
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
@@ -63,7 +63,7 @@ suspend fun WorkManager.awaitWorkInfoById(id: UUID): WorkInfo? {
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List<WorkInfo> {
|
||||
return getWorkInfosForUniqueWork(name).await().orEmpty()
|
||||
return getWorkInfosForUniqueWork(name).await()
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
|
||||
@@ -2,39 +2,41 @@ package org.koitharu.kotatsu.core.zip
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.ArraySet
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Closeable
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.withChildren
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
class ZipOutput(
|
||||
val file: File,
|
||||
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
|
||||
private val compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
|
||||
) : Closeable {
|
||||
|
||||
private val entryNames = ArraySet<String>()
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private val output = ZipOutputStream(file.outputStream()).apply {
|
||||
setLevel(compressionLevel)
|
||||
// FIXME: Deflater has been closed
|
||||
private var cachedOutput: ZipOutputStream? = null
|
||||
private var append: Boolean = false
|
||||
|
||||
@Blocking
|
||||
fun put(name: String, file: File): Boolean = withOutput { output ->
|
||||
output.appendFile(file, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun put(name: String, file: File): Boolean {
|
||||
return output.appendFile(file, name)
|
||||
@Blocking
|
||||
fun put(name: String, content: String): Boolean = withOutput { output ->
|
||||
output.appendText(content, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun put(name: String, content: String): Boolean {
|
||||
return output.appendText(content, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@Blocking
|
||||
fun addDirectory(name: String): Boolean {
|
||||
val entry = if (name.endsWith("/")) {
|
||||
ZipEntry(name)
|
||||
@@ -42,24 +44,8 @@ class ZipOutput(
|
||||
ZipEntry("$name/")
|
||||
}
|
||||
return if (entryNames.add(entry.name)) {
|
||||
output.putNextEntry(entry)
|
||||
output.closeEntry()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
|
||||
return if (entryNames.add(entry.name)) {
|
||||
val zipEntry = ZipEntry(entry.name)
|
||||
output.putNextEntry(zipEntry)
|
||||
try {
|
||||
other.getInputStream(entry).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} finally {
|
||||
withOutput { output ->
|
||||
output.putNextEntry(entry)
|
||||
output.closeEntry()
|
||||
}
|
||||
true
|
||||
@@ -68,15 +54,39 @@ class ZipOutput(
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
output.finish()
|
||||
output.flush()
|
||||
@Blocking
|
||||
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
|
||||
return if (entryNames.add(entry.name)) {
|
||||
val zipEntry = ZipEntry(entry.name)
|
||||
withOutput { output ->
|
||||
output.putNextEntry(zipEntry)
|
||||
try {
|
||||
other.getInputStream(entry).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} finally {
|
||||
output.closeEntry()
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
fun finish() = withOutput { output ->
|
||||
output.finish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun close() {
|
||||
if (isClosed.compareAndSet(false, true)) {
|
||||
output.close()
|
||||
try {
|
||||
cachedOutput?.close()
|
||||
} catch (e: NullPointerException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
cachedOutput = null
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@@ -128,4 +138,30 @@ class ZipOutput(
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun <T> withOutput(block: (ZipOutputStream) -> T): T {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.AT_LEAST_ONCE)
|
||||
}
|
||||
return try {
|
||||
(cachedOutput ?: newOutput(append)).withOutputImpl(block).also {
|
||||
append = true // after 1st success write
|
||||
}
|
||||
} catch (e: NullPointerException) { // probably NullPointerException: Deflater has been closed
|
||||
newOutput(append).withOutputImpl(block)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> ZipOutputStream.withOutputImpl(block: (ZipOutputStream) -> T): T {
|
||||
val res = block(this)
|
||||
flush()
|
||||
return res
|
||||
}
|
||||
|
||||
private fun newOutput(append: Boolean) = ZipOutputStream(FileOutputStream(file, append)).also {
|
||||
it.setLevel(compressionLevel)
|
||||
cachedOutput?.closeQuietly()
|
||||
cachedOutput = it
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.domain
|
||||
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.model.findChapter
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
@@ -33,8 +32,8 @@ class ProgressUpdateUseCase @Inject constructor(
|
||||
} else {
|
||||
seed
|
||||
}
|
||||
val chapter = details.findChapter(history.chapterId) ?: return PROGRESS_NONE
|
||||
val chapters = details.getChapters(chapter.branch) ?: return PROGRESS_NONE
|
||||
val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE
|
||||
val chapters = details.getChapters(chapter.branch)
|
||||
val chaptersCount = chapters.size
|
||||
if (chaptersCount == 0) {
|
||||
return PROGRESS_NONE
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.koitharu.kotatsu.details.domain
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.details.data.ReadingTime
|
||||
import org.koitharu.kotatsu.parsers.util.findById
|
||||
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -6,7 +6,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
@@ -19,6 +18,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.findById
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -34,7 +34,7 @@ class MangaPrefetchService : CoroutineIntentService() {
|
||||
@Inject
|
||||
lateinit var historyRepository: HistoryRepository
|
||||
|
||||
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_PREFETCH_DETAILS -> prefetchDetails(
|
||||
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
|
||||
@@ -50,7 +50,7 @@ class MangaPrefetchService : CoroutineIntentService() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) = Unit
|
||||
override fun IntentJobContext.onError(error: Throwable) = Unit
|
||||
|
||||
private suspend fun prefetchDetails(manga: Manga) {
|
||||
val source = mangaRepositoryFactory.create(manga.source)
|
||||
|
||||
@@ -25,7 +25,7 @@ import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
@@ -127,7 +127,7 @@ class DetailsActivity :
|
||||
View.OnClickListener,
|
||||
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
|
||||
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark>,
|
||||
OnContextClickListenerCompat {
|
||||
OnContextClickListenerCompat, SwipeRefreshLayout.OnRefreshListener {
|
||||
|
||||
@Inject
|
||||
lateinit var shortcutManager: AppShortcutManager
|
||||
@@ -165,6 +165,7 @@ class DetailsActivity :
|
||||
viewBinding.infoLayout.chipSource.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipSize.setOnClickListener(this)
|
||||
viewBinding.textViewDescription.addOnLayoutChangeListener(this)
|
||||
viewBinding.swipeRefreshLayout.setOnRefreshListener(this)
|
||||
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
|
||||
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||
viewBinding.chipsTags.onChipClickListener = this
|
||||
@@ -349,6 +350,10 @@ class DetailsActivity :
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
viewModel.reload()
|
||||
}
|
||||
|
||||
override fun onDraw() {
|
||||
viewBinding.run {
|
||||
buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE ||
|
||||
@@ -420,18 +425,7 @@ class DetailsActivity :
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
val button = viewBinding.buttonDownload ?: return
|
||||
if (isLoading) {
|
||||
button.setImageDrawable(
|
||||
CircularProgressDrawable(this).also {
|
||||
it.setStyle(CircularProgressDrawable.LARGE)
|
||||
it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
|
||||
it.start()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
button.setImageResource(R.drawable.ic_download)
|
||||
}
|
||||
viewBinding.swipeRefreshLayout.isRefreshing = isLoading
|
||||
}
|
||||
|
||||
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.isNetworkError
|
||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
|
||||
@@ -38,7 +39,7 @@ class DetailsErrorObserver(
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -47,6 +46,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.findById
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.details.ui.adapter
|
||||
import android.graphics.Typeface
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.model.formatNumber
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
|
||||
@@ -22,7 +21,7 @@ fun chapterGridItemAD(
|
||||
|
||||
bind { payloads ->
|
||||
if (payloads.isEmpty()) {
|
||||
binding.textViewTitle.text = item.chapter.formatNumber() ?: "?"
|
||||
binding.textViewTitle.text = item.chapter.numberString() ?: "?"
|
||||
}
|
||||
binding.imageViewNew.isVisible = item.isNew
|
||||
binding.imageViewCurrent.isVisible = item.isCurrent
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.ui.adapter
|
||||
|
||||
import android.content.Context
|
||||
import org.koitharu.kotatsu.core.model.formatNumber
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
@@ -33,7 +32,7 @@ class ChaptersAdapter(
|
||||
findHeader(position)?.getText(context)
|
||||
} else {
|
||||
val chapter = (items.getOrNull(position) as? ChapterListItem)?.chapter ?: return null
|
||||
if (chapter.number > 0) chapter.formatNumber() else null
|
||||
chapter.numberString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui.model
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import org.jsoup.internal.StringUtil.StringJoiner
|
||||
import org.koitharu.kotatsu.core.model.formatNumber
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import kotlin.experimental.and
|
||||
@@ -53,7 +52,7 @@ data class ChapterListItem(
|
||||
|
||||
private fun buildDescription(): String {
|
||||
val joiner = StringJoiner(" • ")
|
||||
chapter.formatNumber()?.let {
|
||||
chapter.numberString()?.let {
|
||||
joiner.add("#").append(it)
|
||||
}
|
||||
uploadDate?.let { date ->
|
||||
|
||||
@@ -166,8 +166,9 @@ abstract class ChaptersPagesViewModel(
|
||||
|
||||
fun download(chaptersIds: Set<Long>?, allowMeteredNetwork: Boolean) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val manga = requireManga()
|
||||
val task = DownloadTask(
|
||||
mangaId = requireManga().id,
|
||||
mangaId = manga.id,
|
||||
isPaused = false,
|
||||
isSilent = false,
|
||||
chaptersIds = chaptersIds?.toLongArray(),
|
||||
@@ -175,7 +176,7 @@ abstract class ChaptersPagesViewModel(
|
||||
format = null,
|
||||
allowMeteredNetwork = allowMeteredNetwork,
|
||||
)
|
||||
downloadScheduler.schedule(setOf(task))
|
||||
downloadScheduler.schedule(setOf(manga to task))
|
||||
onDownloadStarted.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,10 +62,12 @@ class ChaptersSelectionCallback(
|
||||
R.id.action_save -> {
|
||||
val snapshot = controller.snapshot()
|
||||
mode?.finish()
|
||||
commonAlertDialogs.askForDownloadOverMeteredNetwork(
|
||||
context = recyclerView.context,
|
||||
onConfirmed = { viewModel.download(snapshot, it) },
|
||||
)
|
||||
if (snapshot.isNotEmpty()) {
|
||||
commonAlertDialogs.askForDownloadOverMeteredNetwork(
|
||||
context = recyclerView.context,
|
||||
onConfirmed = { viewModel.download(snapshot, it) },
|
||||
)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -79,16 +79,14 @@ class MangaPageFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Response.toNetworkResponse(): NetworkResponse {
|
||||
return NetworkResponse(
|
||||
code = code,
|
||||
requestMillis = sentRequestAtMillis,
|
||||
responseMillis = receivedResponseAtMillis,
|
||||
headers = headers.toNetworkHeaders(),
|
||||
body = body?.source()?.let(::NetworkResponseBody),
|
||||
delegate = this,
|
||||
)
|
||||
}
|
||||
private fun Response.toNetworkResponse() = NetworkResponse(
|
||||
code = code,
|
||||
requestMillis = sentRequestAtMillis,
|
||||
responseMillis = receivedResponseAtMillis,
|
||||
headers = headers.toNetworkHeaders(),
|
||||
body = body?.source()?.let(::NetworkResponseBody),
|
||||
delegate = this,
|
||||
)
|
||||
|
||||
private fun Headers.toNetworkHeaders(): NetworkHeaders {
|
||||
val headers = NetworkHeaders.Builder()
|
||||
|
||||
@@ -2,8 +2,13 @@ package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
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
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
@@ -20,10 +25,12 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
@@ -34,16 +41,18 @@ import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PagesFragment :
|
||||
BaseFragment<FragmentPagesBinding>(),
|
||||
OnListItemClickListener<PageThumbnail> {
|
||||
OnListItemClickListener<PageThumbnail>, ListSelectionController.Callback {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
@@ -51,17 +60,23 @@ class PagesFragment :
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
@Inject
|
||||
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
|
||||
|
||||
private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
|
||||
private val viewModel by viewModels<PagesViewModel>()
|
||||
private lateinit var pageSaveHelper: PageSaveHelper
|
||||
|
||||
private var thumbnailsAdapter: PageThumbnailAdapter? = null
|
||||
private var spanResolver: GridSpanResolver? = null
|
||||
private var scrollListener: ScrollListener? = null
|
||||
private var selectionController: ListSelectionController? = null
|
||||
|
||||
private val spanSizeLookup = SpanSizeLookup()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||
combine(
|
||||
parentViewModel.mangaDetails,
|
||||
parentViewModel.readingState,
|
||||
@@ -83,6 +98,12 @@ class PagesFragment :
|
||||
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
spanResolver = GridSpanResolver(binding.root.resources)
|
||||
selectionController = ListSelectionController(
|
||||
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
|
||||
decoration = PagesSelectionDecoration(binding.root.context),
|
||||
registryOwner = this,
|
||||
callback = this,
|
||||
)
|
||||
thumbnailsAdapter = PageThumbnailAdapter(
|
||||
coil = coil,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
@@ -91,6 +112,7 @@ class PagesFragment :
|
||||
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
checkNotNull(selectionController).attachToRecyclerView(this)
|
||||
adapter = thumbnailsAdapter
|
||||
setHasFixedSize(true)
|
||||
PagerNestedScrollHelper(this).bind(viewLifecycleOwner)
|
||||
@@ -103,6 +125,7 @@ class PagesFragment :
|
||||
}
|
||||
parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged)
|
||||
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(binding.recyclerView))
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
|
||||
viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) }
|
||||
@@ -113,6 +136,7 @@ class PagesFragment :
|
||||
spanResolver = null
|
||||
scrollListener = null
|
||||
thumbnailsAdapter = null
|
||||
selectionController = null
|
||||
spanSizeLookup.invalidateCache()
|
||||
super.onDestroyView()
|
||||
}
|
||||
@@ -120,6 +144,9 @@ class PagesFragment :
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
override fun onItemClick(item: PageThumbnail, view: View) {
|
||||
if (selectionController?.onItemClick(item.page.id) == true) {
|
||||
return
|
||||
}
|
||||
val listener = findParentCallback(ReaderNavigationCallback::class.java)
|
||||
if (listener != null && listener.onPageSelected(item.page)) {
|
||||
dismissParentDialog()
|
||||
@@ -133,6 +160,39 @@ class PagesFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: PageThumbnail, view: View): Boolean {
|
||||
return selectionController?.onItemLongClick(view, item.page.id) ?: false
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: PageThumbnail, view: View): Boolean {
|
||||
return selectionController?.onItemContextClick(view, item.page.id) ?: false
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||
viewBinding?.recyclerView?.invalidateItemDecorations()
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
menu: Menu,
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_pages, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_save -> {
|
||||
viewModel.savePages(pageSaveHelper, collectSelectedPages())
|
||||
mode?.finish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onThumbnailsChanged(list: List<ListModel>) {
|
||||
val adapter = thumbnailsAdapter ?: return
|
||||
if (adapter.itemCount == 0) {
|
||||
@@ -172,6 +232,18 @@ class PagesFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectSelectedPages(): Set<ReaderPage> {
|
||||
val checkedIds = selectionController?.peekCheckedIds() ?: return emptySet()
|
||||
val items = thumbnailsAdapter?.items ?: return emptySet()
|
||||
val result = ArraySet<ReaderPage>(checkedIds.size)
|
||||
for (item in items) {
|
||||
if (item is PageThumbnail && item.page.id in checkedIds) {
|
||||
result.add(item.page)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private inner class ScrollListener : BoundsScrollListener(3, 3) {
|
||||
|
||||
override fun onScrolledToStart(recyclerView: RecyclerView) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
|
||||
class PagesSavedObserver(
|
||||
private val snackbarHost: View,
|
||||
) : FlowCollector<Collection<Uri>> {
|
||||
|
||||
override suspend fun emit(value: Collection<Uri>) {
|
||||
val msg = when (value.size) {
|
||||
0 -> R.string.nothing_found
|
||||
1 -> R.string.page_saved
|
||||
else -> R.string.pages_saved
|
||||
}
|
||||
val snackbar = Snackbar.make(snackbarHost, msg, Snackbar.LENGTH_LONG)
|
||||
value.singleOrNull()?.let { uri ->
|
||||
snackbar.setAction(R.string.share) {
|
||||
ShareHelper(snackbarHost.context).shareImage(uri)
|
||||
}
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
|
||||
|
||||
class PagesSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
|
||||
|
||||
override fun getItemId(parent: RecyclerView, child: View): Long {
|
||||
val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
|
||||
val item = holder.getItem(PageThumbnail::class.java) ?: return RecyclerView.NO_ID
|
||||
return item.page.id
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -10,12 +11,17 @@ import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.firstNotNull
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
|
||||
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -32,6 +38,7 @@ class PagesViewModel @Inject constructor(
|
||||
val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
|
||||
val isLoadingUp = MutableStateFlow(false)
|
||||
val isLoadingDown = MutableStateFlow(false)
|
||||
val onPageSaved = MutableEventFlow<Collection<Uri>>()
|
||||
|
||||
val gridScale = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
@@ -73,6 +80,25 @@ class PagesViewModel @Inject constructor(
|
||||
loadingNextJob = loadPrevNextChapter(isNext = true)
|
||||
}
|
||||
|
||||
fun savePages(
|
||||
pageSaveHelper: PageSaveHelper,
|
||||
pages: Set<ReaderPage>,
|
||||
) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val manga = state.requireValue().details.toManga()
|
||||
val tasks = pages.map {
|
||||
PageSaveHelper.Task(
|
||||
manga = manga,
|
||||
chapter = manga.requireChapterById(it.chapterId),
|
||||
pageNumber = it.index + 1,
|
||||
page = it.toMangaPage(),
|
||||
)
|
||||
}
|
||||
val dest = pageSaveHelper.save(tasks)
|
||||
onPageSaved.call(dest)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doInit(state: State) {
|
||||
chaptersLoader.init(state.details)
|
||||
val initialChapterId = state.readerState?.chapterId?.takeIf {
|
||||
|
||||
@@ -44,7 +44,7 @@ interface ChaptersSelectMacro {
|
||||
) : ChaptersSelectMacro {
|
||||
|
||||
override fun getChaptersIds(mangaId: Long, chapters: List<MangaChapter>): Set<Long> {
|
||||
val result = ArraySet<Long>(chaptersCount)
|
||||
val result = ArraySet<Long>(minOf(chaptersCount, chapters.size))
|
||||
for (c in chapters) {
|
||||
if (c.branch == branch) {
|
||||
result.add(c.id)
|
||||
@@ -72,7 +72,7 @@ interface ChaptersSelectMacro {
|
||||
val currentChapterId = currentChaptersIds.getOrDefault(mangaId, chapters.first().id)
|
||||
var branch: String? = null
|
||||
var isAdding = false
|
||||
val result = ArraySet<Long>(chaptersCount)
|
||||
val result = ArraySet<Long>(minOf(chaptersCount, chapters.size))
|
||||
for (c in chapters) {
|
||||
if (!isAdding) {
|
||||
if (c.id == currentChapterId) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
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.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.DownloadFormat
|
||||
@@ -29,16 +28,15 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DownloadDialogViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val scheduler: DownloadWorker.Scheduler,
|
||||
private val localStorageManager: LocalStorageManager,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
@@ -50,7 +48,7 @@ class DownloadDialogViewModel @Inject constructor(
|
||||
val manga = savedStateHandle.require<Array<ParcelableManga>>(DownloadDialogFragment.ARG_MANGA).map {
|
||||
it.manga
|
||||
}
|
||||
private val mangaDetails = SuspendLazy {
|
||||
private val mangaDetails = suspendLazy {
|
||||
coroutineScope {
|
||||
manga.map { m ->
|
||||
async { m.getDetails() }
|
||||
@@ -94,8 +92,7 @@ class DownloadDialogViewModel @Inject constructor(
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val tasks = mangaDetails.get().map { m ->
|
||||
val chapters = checkNotNull(m.chapters) { "Manga \"${m.title}\" cannot be loaded" }
|
||||
mangaDataRepository.storeManga(m)
|
||||
DownloadTask(
|
||||
m to DownloadTask(
|
||||
mangaId = m.id,
|
||||
isPaused = !startNow,
|
||||
isSilent = false,
|
||||
|
||||
@@ -22,10 +22,8 @@ import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.formatNumber
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
@@ -309,7 +307,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
return chapters.mapNotNullTo(ArrayList(size)) {
|
||||
if (chapterIds == null || it.id in chapterIds) {
|
||||
DownloadChapter(
|
||||
number = it.formatNumber(),
|
||||
number = it.numberString(),
|
||||
name = it.name,
|
||||
isDownloaded = it.id in localChapters,
|
||||
)
|
||||
@@ -327,6 +325,6 @@ class DownloadsViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
|
||||
(mangaRepositoryFactory.create(manga.source) as ParserMangaRepository).getDetails(manga)
|
||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package org.koitharu.kotatsu.download.ui.worker
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.collection.MutableObjectLongMap
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
class DownloadSlowdownDispatcher(
|
||||
@Singleton
|
||||
class DownloadSlowdownDispatcher @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val defaultDelay: Long,
|
||||
) {
|
||||
private val timeMap = MutableObjectLongMap<MangaSource>()
|
||||
private val defaultDelay = 1_600L
|
||||
|
||||
suspend fun delay(source: MangaSource) {
|
||||
val repo = mangaRepositoryFactory.create(source) as? ParserMangaRepository ?: return
|
||||
@@ -19,11 +23,11 @@ class DownloadSlowdownDispatcher(
|
||||
}
|
||||
val lastRequest = synchronized(timeMap) {
|
||||
val res = timeMap.getOrDefault(source, 0L)
|
||||
timeMap[source] = System.currentTimeMillis()
|
||||
timeMap[source] = SystemClock.elapsedRealtime()
|
||||
res
|
||||
}
|
||||
if (lastRequest != 0L) {
|
||||
delay(lastRequest + defaultDelay - System.currentTimeMillis())
|
||||
delay(lastRequest + defaultDelay - SystemClock.elapsedRealtime())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.TempFileFilter
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.MangaLock
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
@@ -101,6 +101,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val settings: AppSettings,
|
||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||
private val slowdownDispatcher: DownloadSlowdownDispatcher,
|
||||
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||
notificationFactoryFactory: DownloadNotificationFactory.Factory,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
@@ -108,7 +109,6 @@ class DownloadWorker @AssistedInject constructor(
|
||||
private val task = DownloadTask(params.inputData)
|
||||
private val notificationFactory = notificationFactoryFactory.create(uuid = params.id, isSilent = task.isSilent)
|
||||
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
|
||||
|
||||
@Volatile
|
||||
private var lastPublishedState: DownloadState? = null
|
||||
@@ -262,7 +262,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
if (output.flushChapter(chapter.value)) {
|
||||
runCatchingCancellable {
|
||||
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
|
||||
localStorageChanges.emit(LocalMangaParser(output.rootFile).getManga(withDetails = false))
|
||||
}.onFailure(Throwable::printStackTraceDebug)
|
||||
}
|
||||
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
|
||||
@@ -270,7 +270,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
publishState(currentState.copy(isIndeterminate = true, eta = -1L, isStuck = false))
|
||||
output.mergeWithExisting()
|
||||
output.finish()
|
||||
val localManga = LocalMangaInput.of(output.rootFile).getManga()
|
||||
val localManga = LocalMangaParser(output.rootFile).getManga(withDetails = false)
|
||||
localStorageChanges.emit(localManga)
|
||||
publishState(currentState.copy(localManga = localManga, eta = -1L, isStuck = false))
|
||||
} catch (e: Exception) {
|
||||
@@ -433,6 +433,7 @@ class DownloadWorker @AssistedInject constructor(
|
||||
@Reusable
|
||||
class Scheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
|
||||
@@ -507,11 +508,12 @@ class DownloadWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun schedule(tasks: Collection<DownloadTask>) {
|
||||
suspend fun schedule(tasks: Collection<Pair<Manga, DownloadTask>>) {
|
||||
if (tasks.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val requests = tasks.map { task ->
|
||||
val requests = tasks.map { (manga, task) ->
|
||||
mangaDataRepository.storeManga(manga)
|
||||
OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setConstraints(createConstraints(task.allowMeteredNetwork))
|
||||
.addTag(TAG)
|
||||
@@ -535,7 +537,6 @@ class DownloadWorker @AssistedInject constructor(
|
||||
const val MAX_PAGES_PARALLELISM = 4
|
||||
const val DOWNLOAD_ERROR_DELAY = 2_000L
|
||||
const val MAX_RETRY_DELAY = 7_200_000L // 2 hours
|
||||
const val SLOWDOWN_DELAY = 200L
|
||||
const val TAG = "download"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ 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.model.YEAR_MIN
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
|
||||
import java.util.Calendar
|
||||
@@ -59,7 +59,7 @@ class FilterCoordinator @Inject constructor(
|
||||
private val currentSortOrder = MutableStateFlow(repository.defaultSortOrder)
|
||||
|
||||
private val availableSortOrders = repository.sortOrders
|
||||
private val filterOptions = SuspendLazy { repository.getFilterOptions() }
|
||||
private val filterOptions = suspendLazy { repository.getFilterOptions() }
|
||||
val capabilities = repository.filterCapabilities
|
||||
|
||||
val mangaSource: MangaSource
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
@@ -356,5 +357,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
private const val TAG = "FilterSheet"
|
||||
|
||||
fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
|
||||
|
||||
fun isSupported(fragment: Fragment) = fragment.activity is FilterCoordinator.Owner
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,8 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
|
||||
|
||||
suspend fun deleteAfter(minDate: Long) = setDeletedAtAfter(minDate, System.currentTimeMillis())
|
||||
|
||||
suspend fun deleteNotFavorite() = setDeletedAtNotFavorite(System.currentTimeMillis())
|
||||
|
||||
suspend fun clear() = setDeletedAtAfter(0L, System.currentTimeMillis())
|
||||
|
||||
suspend fun update(entity: HistoryEntity) = update(
|
||||
@@ -157,6 +159,9 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
|
||||
@Query("UPDATE history SET deleted_at = :deletedAt WHERE created_at >= :minDate AND deleted_at = 0")
|
||||
protected abstract suspend fun setDeletedAtAfter(minDate: Long, deletedAt: Long)
|
||||
|
||||
@Query("UPDATE history SET deleted_at = :deletedAt WHERE deleted_at = 0 AND NOT EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)")
|
||||
protected abstract suspend fun setDeletedAtNotFavorite(deletedAt: Long)
|
||||
|
||||
@Transaction
|
||||
@RawQuery(observedEntities = [HistoryEntity::class])
|
||||
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>>
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.db.entity.toMangaList
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||
@@ -30,6 +29,7 @@ import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.findById
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
|
||||
@@ -164,6 +164,10 @@ class HistoryRepository @Inject constructor(
|
||||
db.getHistoryDao().deleteAfter(minDate)
|
||||
}
|
||||
|
||||
suspend fun deleteNotFavorite() {
|
||||
db.getHistoryDao().deleteNotFavorite()
|
||||
}
|
||||
|
||||
suspend fun delete(ids: Collection<Long>): ReversibleHandle {
|
||||
db.withTransaction {
|
||||
for (id in ids) {
|
||||
|
||||
@@ -53,6 +53,7 @@ class HistoryListMenuProvider(
|
||||
arrayOf(
|
||||
context.getString(R.string.last_2_hours),
|
||||
context.getString(R.string.today),
|
||||
context.getString(R.string.not_in_favorites),
|
||||
context.getString(R.string.clear_all_history),
|
||||
),
|
||||
selectionListener.selection,
|
||||
@@ -61,13 +62,12 @@ class HistoryListMenuProvider(
|
||||
setIcon(R.drawable.ic_delete_all)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.clear) { _, _ ->
|
||||
val minDate = when (selectionListener.selection) {
|
||||
0 -> Instant.now().minus(2, ChronoUnit.HOURS)
|
||||
1 -> LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
2 -> Instant.EPOCH
|
||||
else -> return@setPositiveButton
|
||||
when (selectionListener.selection) {
|
||||
0 -> viewModel.clearHistory(Instant.now().minus(2, ChronoUnit.HOURS))
|
||||
1 -> viewModel.clearHistory(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant())
|
||||
2 -> viewModel.removeNotFavorite()
|
||||
3 -> viewModel.clearHistory(null)
|
||||
}
|
||||
viewModel.clearHistory(minDate)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
@@ -101,9 +101,9 @@ class HistoryListViewModel @Inject constructor(
|
||||
|
||||
override fun onRetry() = Unit
|
||||
|
||||
fun clearHistory(minDate: Instant) {
|
||||
fun clearHistory(minDate: Instant?) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val stringRes = if (minDate <= Instant.EPOCH) {
|
||||
val stringRes = if (minDate == null) {
|
||||
repository.clear()
|
||||
R.string.history_cleared
|
||||
} else {
|
||||
@@ -114,6 +114,13 @@ class HistoryListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNotFavorite() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
repository.deleteNotFavorite()
|
||||
onActionDone.call(ReversibleAction(R.string.removed_from_history, null))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFromHistory(ids: Set<Long>) {
|
||||
if (ids.isEmpty()) {
|
||||
return
|
||||
|
||||
@@ -169,7 +169,7 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
||||
|
||||
private fun setDrawable(drawable: Drawable?) {
|
||||
if (drawable != null) {
|
||||
view.setImage(ImageSource.Bitmap(drawable.toBitmap()))
|
||||
view.setImage(ImageSource.bitmap(drawable.toBitmap()))
|
||||
} else {
|
||||
view.recycle()
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.list.ui.model.QuickFilter
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
|
||||
abstract class MangaListQuickFilter(
|
||||
private val settings: AppSettings,
|
||||
) : QuickFilterListener {
|
||||
|
||||
private val appliedFilter = MutableStateFlow<Set<ListFilterOption>>(emptySet())
|
||||
private val availableFilterOptions = SuspendLazy {
|
||||
private val availableFilterOptions = suspendLazy {
|
||||
getAvailableFilterOptions()
|
||||
}
|
||||
|
||||
@@ -50,7 +51,7 @@ abstract class MangaListQuickFilter(
|
||||
if (!settings.isQuickFilterEnabled) {
|
||||
return null
|
||||
}
|
||||
val availableOptions = availableFilterOptions.tryGet().getOrNull()?.map { option ->
|
||||
val availableOptions = availableFilterOptions.getOrNull()?.map { option ->
|
||||
ChipsView.ChipModel(
|
||||
title = option.titleText,
|
||||
titleResId = option.titleResId,
|
||||
|
||||
@@ -115,9 +115,9 @@ abstract class MangaListFragment :
|
||||
with(binding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
adapter = listAdapter
|
||||
checkNotNull(selectionController).attachToRecyclerView(binding.recyclerView)
|
||||
checkNotNull(selectionController).attachToRecyclerView(this)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
addOnScrollListener(paginationListener!!)
|
||||
addOnScrollListener(checkNotNull(paginationListener))
|
||||
fastScroller.setFastScrollListener(this@MangaListFragment)
|
||||
}
|
||||
with(binding.swipeRefreshLayout) {
|
||||
|
||||
@@ -2,13 +2,14 @@ package org.koitharu.kotatsu.local.data
|
||||
|
||||
import java.io.File
|
||||
|
||||
private fun isCbzExtension(ext: String?): Boolean {
|
||||
private fun isZipExtension(ext: String?): Boolean {
|
||||
return ext.equals("cbz", ignoreCase = true) || ext.equals("zip", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun hasCbzExtension(string: String): Boolean {
|
||||
fun hasZipExtension(string: String): Boolean {
|
||||
val ext = string.substringAfterLast('.', "")
|
||||
return isCbzExtension(ext)
|
||||
return isZipExtension(ext)
|
||||
}
|
||||
|
||||
fun File.hasCbzExtension() = isCbzExtension(extension)
|
||||
val File.isZipArchive: Boolean
|
||||
get() = isFile && isZipExtension(extension)
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -19,7 +20,7 @@ import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.withChildren
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
|
||||
import org.koitharu.kotatsu.local.domain.MangaLock
|
||||
@@ -125,15 +126,15 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = when {
|
||||
!manga.isLocal -> requireNotNull(findSavedManga(manga)?.manga) {
|
||||
!manga.isLocal -> requireNotNull(findSavedManga(manga, withDetails = true)?.manga) {
|
||||
"Manga is not local or saved"
|
||||
}
|
||||
|
||||
else -> LocalMangaInput.of(manga).getManga().manga
|
||||
else -> LocalMangaParser(manga.url.toUri()).getManga(withDetails = true).manga
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
return LocalMangaInput.of(chapter).getPages(chapter)
|
||||
return LocalMangaParser(chapter.url.toUri()).getPages(chapter)
|
||||
}
|
||||
|
||||
suspend fun delete(manga: Manga): Boolean {
|
||||
@@ -147,7 +148,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) = lock.withLock(manga) {
|
||||
val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) {
|
||||
val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga, withDetails = false)) {
|
||||
"Manga is not stored on local storage"
|
||||
}.manga
|
||||
LocalMangaUtil(subject).deleteChapters(ids)
|
||||
@@ -156,27 +157,27 @@ class LocalMangaRepository @Inject constructor(
|
||||
|
||||
suspend fun getRemoteManga(localManga: Manga): Manga? {
|
||||
return runCatchingCancellable {
|
||||
LocalMangaInput.of(localManga).getMangaInfo()?.takeUnless { it.isLocal }
|
||||
LocalMangaParser(localManga.url.toUri()).getMangaInfo()?.takeUnless { it.isLocal }
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
|
||||
suspend fun findSavedManga(remoteManga: Manga, withDetails: Boolean = true): LocalManga? = runCatchingCancellable {
|
||||
// very fast path
|
||||
localMangaIndex.get(remoteManga.id)?.let {
|
||||
return@runCatchingCancellable it
|
||||
localMangaIndex.get(remoteManga.id, withDetails)?.let { cached ->
|
||||
return@runCatchingCancellable cached
|
||||
}
|
||||
// fast path
|
||||
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
|
||||
return it.getManga()
|
||||
LocalMangaParser.find(storageManager.getReadableDirs(), remoteManga)?.let {
|
||||
return it.getManga(withDetails)
|
||||
}
|
||||
// slow path
|
||||
val files = getAllFiles()
|
||||
return channelFlow {
|
||||
for (file in files) {
|
||||
launch {
|
||||
val mangaInput = LocalMangaInput.ofOrNull(file)
|
||||
val mangaInput = LocalMangaParser.getOrNull(file)
|
||||
runCatchingCancellable {
|
||||
val mangaInfo = mangaInput?.getMangaInfo()
|
||||
if (mangaInfo != null && mangaInfo.id == remoteManga.id) {
|
||||
@@ -187,7 +188,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
}.firstOrNull()?.getManga()
|
||||
}.firstOrNull()?.getManga(withDetails)
|
||||
}.onSuccess { x: LocalManga? ->
|
||||
if (x != null) {
|
||||
localMangaIndex.put(x)
|
||||
@@ -237,7 +238,7 @@ class LocalMangaRepository @Inject constructor(
|
||||
for (file in files) {
|
||||
launch(dispatcher) {
|
||||
runCatchingCancellable {
|
||||
LocalMangaInput.ofOrNull(file)?.getManga()
|
||||
LocalMangaParser.getOrNull(file)?.getManga(withDetails = false)
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.onSuccess { m ->
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.buffer
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
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.MangaSource
|
||||
@@ -18,6 +24,7 @@ import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import java.io.File
|
||||
|
||||
@@ -186,15 +193,28 @@ class MangaIndex(source: String?) {
|
||||
|
||||
companion object {
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun read(file: File): MangaIndex? {
|
||||
if (file.exists() && file.canRead()) {
|
||||
val text = file.readText()
|
||||
if (text.length > 2) {
|
||||
return MangaIndex(text)
|
||||
fun read(fileSystem: FileSystem, path: Path): MangaIndex? = runCatchingCancellable {
|
||||
if (!fileSystem.exists(path)) {
|
||||
return@runCatchingCancellable null
|
||||
}
|
||||
val text = fileSystem.source(path).use {
|
||||
it.buffer().use { buffer ->
|
||||
buffer.readUtf8()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (text.length > 2) {
|
||||
MangaIndex(text)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
@Blocking
|
||||
@WorkerThread
|
||||
fun read(file: File): MangaIndex? = read(FileSystem.SYSTEM, file.toOkioPath())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ import org.koitharu.kotatsu.core.util.ext.subdir
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfWriteable
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -32,13 +32,13 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
private val cacheDir = SuspendLazy {
|
||||
private val cacheDir = suspendLazy {
|
||||
val dirs = context.externalCacheDirs + context.cacheDir
|
||||
dirs.firstNotNullOf {
|
||||
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
|
||||
}
|
||||
}
|
||||
private val lruCache = SuspendLazy {
|
||||
private val lruCache = suspendLazy {
|
||||
val dir = cacheDir.get()
|
||||
val availableSize = (getAvailableSize() * 0.8).toLong()
|
||||
val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN)
|
||||
|
||||
@@ -17,8 +17,8 @@ import org.koitharu.kotatsu.core.util.ext.resolveName
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.data.hasCbzExtension
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.hasZipExtension
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
@@ -46,7 +46,7 @@ class SingleMangaImporter @Inject constructor(
|
||||
private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) {
|
||||
val contentResolver = storageManager.contentResolver
|
||||
val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
|
||||
if (!hasCbzExtension(name)) {
|
||||
if (!hasZipExtension(name)) {
|
||||
throw UnsupportedFileException("Unsupported file $name on $uri")
|
||||
}
|
||||
val dest = File(getOutputDir(), name)
|
||||
@@ -57,7 +57,7 @@ class SingleMangaImporter @Inject constructor(
|
||||
output.writeAllCancellable(source.source())
|
||||
}
|
||||
} ?: throw IOException("Cannot open input stream: $uri")
|
||||
LocalMangaInput.of(dest).getManga()
|
||||
LocalMangaParser(dest).getManga(withDetails = false)
|
||||
}
|
||||
|
||||
private suspend fun importDirectory(uri: Uri): LocalManga {
|
||||
@@ -69,7 +69,7 @@ class SingleMangaImporter @Inject constructor(
|
||||
for (docFile in root.listFiles()) {
|
||||
docFile.copyTo(dest)
|
||||
}
|
||||
return LocalMangaInput.of(dest).getManga()
|
||||
return LocalMangaParser(dest).getManga(withDetails = false)
|
||||
}
|
||||
|
||||
private suspend fun DocumentFile.copyTo(destDir: File) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
@@ -57,7 +57,7 @@ class LocalMangaIndex @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(mangaId: Long): LocalManga? {
|
||||
suspend fun get(mangaId: Long, withDetails: Boolean): LocalManga? {
|
||||
updateIfRequired()
|
||||
var path = db.getLocalMangaIndexDao().findPath(mangaId)
|
||||
if (path == null && mutex.isLocked) { // wait for updating complete
|
||||
@@ -67,7 +67,7 @@ class LocalMangaIndex @Inject constructor(
|
||||
return null
|
||||
}
|
||||
return runCatchingCancellable {
|
||||
LocalMangaInput.of(File(path)).getManga()
|
||||
LocalMangaParser(File(path)).getManga(withDetails)
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.data.input
|
||||
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.creationTime
|
||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||
import org.koitharu.kotatsu.core.util.ext.toListSorted
|
||||
import org.koitharu.kotatsu.core.util.ext.walkCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.withChildren
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.hasCbzExtension
|
||||
import org.koitharu.kotatsu.local.data.hasImageExtension
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||
import java.io.File
|
||||
import java.util.TreeMap
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* Manga {Folder}
|
||||
* |--- index.json (optional)
|
||||
* |--- Chapter 1.cbz
|
||||
* |--- Page 1.png
|
||||
* :
|
||||
* L--- Page x.png
|
||||
* |--- Chapter 2.cbz
|
||||
* :
|
||||
* L--- Chapter x.cbz
|
||||
*/
|
||||
class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
||||
|
||||
override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) {
|
||||
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
|
||||
val mangaUri = root.toUri().toString()
|
||||
val chapterFiles = getChaptersFiles()
|
||||
val info = index?.getMangaInfo()
|
||||
val cover = fileUri(
|
||||
root,
|
||||
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
|
||||
)
|
||||
val manga = info?.copy2(
|
||||
source = LocalMangaSource,
|
||||
url = mangaUri,
|
||||
coverUrl = cover,
|
||||
largeCoverUrl = cover,
|
||||
chapters = info.chapters?.mapIndexedNotNull { i, c ->
|
||||
val fileName = index.getChapterFileName(c.id)
|
||||
val file = if (fileName != null) {
|
||||
chapterFiles[fileName]
|
||||
} else {
|
||||
// old downloads
|
||||
chapterFiles.values.elementAtOrNull(i)
|
||||
} ?: return@mapIndexedNotNull null
|
||||
c.copy(url = file.toUri().toString(), source = LocalMangaSource)
|
||||
},
|
||||
) ?: Manga(
|
||||
id = root.absolutePath.longHashCode(),
|
||||
title = root.name.toHumanReadable(),
|
||||
url = mangaUri,
|
||||
publicUrl = mangaUri,
|
||||
source = LocalMangaSource,
|
||||
coverUrl = findFirstImageEntry().orEmpty(),
|
||||
chapters = chapterFiles.values.mapIndexed { i, f ->
|
||||
MangaChapter(
|
||||
id = "$i${f.name}".longHashCode(),
|
||||
name = f.nameWithoutExtension.toHumanReadable(),
|
||||
number = 0f,
|
||||
volume = 0,
|
||||
source = LocalMangaSource,
|
||||
uploadDate = f.creationTime,
|
||||
url = f.toUri().toString(),
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
},
|
||||
altTitle = null,
|
||||
rating = -1f,
|
||||
isNsfw = false,
|
||||
tags = setOf(),
|
||||
state = null,
|
||||
author = null,
|
||||
largeCoverUrl = null,
|
||||
description = null,
|
||||
)
|
||||
LocalManga(manga, root)
|
||||
}
|
||||
|
||||
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
||||
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
|
||||
index?.getMangaInfo()
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||
val file = chapter.url.toUri().toFile()
|
||||
if (file.isDirectory) {
|
||||
file.withChildren { children ->
|
||||
children
|
||||
.filter { it.isFile && hasImageExtension(it) }
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
}.map {
|
||||
val pageUri = it.toUri().toString()
|
||||
MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
|
||||
}
|
||||
} else {
|
||||
ZipFile(file).use { zip ->
|
||||
zip.entries()
|
||||
.asSequence()
|
||||
.filter { x -> !x.isDirectory }
|
||||
.map { it.name }
|
||||
.toListSorted(AlphanumComparator())
|
||||
.map {
|
||||
val pageUri = zipUri(file, it)
|
||||
MangaPage(
|
||||
id = pageUri.longHashCode(),
|
||||
url = pageUri,
|
||||
preview = null,
|
||||
source = LocalMangaSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
|
||||
|
||||
private fun getChaptersFiles() = root.walkCompat(includeDirectories = true)
|
||||
.filter { it != root && it.isChapterDirectory() || it.hasCbzExtension() }
|
||||
.associateByTo(TreeMap(AlphanumComparator())) { it.name }
|
||||
|
||||
private fun findFirstImageEntry(): String? {
|
||||
return root.walkCompat(includeDirectories = false)
|
||||
.firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
|
||||
?: run {
|
||||
val cbz = root.walkCompat(includeDirectories = false)
|
||||
.firstOrNull { it.hasCbzExtension() } ?: return null
|
||||
ZipFile(cbz).use { zip ->
|
||||
zip.entries().asSequence()
|
||||
.firstOrNull { !it.isDirectory && hasImageExtension(it.name) }
|
||||
?.let { zipUri(cbz, it.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileUri(base: File, name: String): String {
|
||||
return File(base, name).toUri().toString()
|
||||
}
|
||||
|
||||
private fun File.isChapterDirectory(): Boolean {
|
||||
return isDirectory && withChildren { children -> children.any { hasImageExtension(it) } }
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.data.input
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.util.ext.toZipUri
|
||||
import org.koitharu.kotatsu.local.data.hasCbzExtension
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||
import java.io.File
|
||||
|
||||
sealed class LocalMangaInput(
|
||||
protected val root: File,
|
||||
) {
|
||||
|
||||
abstract suspend fun getManga(): LocalManga
|
||||
|
||||
abstract suspend fun getMangaInfo(): Manga?
|
||||
|
||||
abstract suspend fun getPages(chapter: MangaChapter): List<MangaPage>
|
||||
|
||||
companion object {
|
||||
|
||||
fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile())
|
||||
|
||||
fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile())
|
||||
|
||||
fun of(file: File): LocalMangaInput = when {
|
||||
file.isDirectory -> LocalMangaDirInput(file)
|
||||
else -> LocalMangaZipInput(file)
|
||||
}
|
||||
|
||||
fun ofOrNull(file: File): LocalMangaInput? = when {
|
||||
file.isDirectory -> LocalMangaDirInput(file)
|
||||
hasCbzExtension(file.name) -> LocalMangaZipInput(file)
|
||||
else -> null
|
||||
}
|
||||
|
||||
suspend fun find(roots: Iterable<File>, manga: Manga): LocalMangaInput? = channelFlow {
|
||||
val fileName = manga.title.toFileNameSafe()
|
||||
for (root in roots) {
|
||||
launch {
|
||||
val dir = File(root, fileName)
|
||||
val zip = File(root, "$fileName.cbz")
|
||||
val input = when {
|
||||
dir.isDirectory -> LocalMangaDirInput(dir)
|
||||
zip.isFile -> LocalMangaZipInput(zip)
|
||||
else -> null
|
||||
}
|
||||
val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull()
|
||||
if (info?.id == manga.id) {
|
||||
send(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.Default).firstOrNull()
|
||||
|
||||
@JvmStatic
|
||||
protected fun zipUri(file: File, entryName: String): String = file.toZipUri(entryName).toString()
|
||||
|
||||
@JvmStatic
|
||||
protected fun Manga.copy2(
|
||||
url: String,
|
||||
coverUrl: String,
|
||||
largeCoverUrl: String,
|
||||
chapters: List<MangaChapter>?,
|
||||
source: MangaSource,
|
||||
) = Manga(
|
||||
id = id,
|
||||
title = title,
|
||||
altTitle = altTitle,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
coverUrl = coverUrl,
|
||||
tags = tags,
|
||||
state = state,
|
||||
author = author,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
description = description,
|
||||
chapters = chapters,
|
||||
source = source,
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
protected fun MangaChapter.copy(
|
||||
url: String,
|
||||
source: MangaSource,
|
||||
) = MangaChapter(
|
||||
id = id,
|
||||
name = name,
|
||||
number = number,
|
||||
volume = volume,
|
||||
url = url,
|
||||
scanlator = scanlator,
|
||||
uploadDate = uploadDate,
|
||||
branch = branch,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package org.koitharu.kotatsu.local.data.input
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.Path.Companion.toPath
|
||||
import okio.openZip
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
|
||||
import org.koitharu.kotatsu.core.util.ext.isFileUri
|
||||
import org.koitharu.kotatsu.core.util.ext.isRegularFile
|
||||
import org.koitharu.kotatsu.core.util.ext.isZipUri
|
||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toListSorted
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.isZipArchive
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput.Companion.ENTRY_NAME_INDEX
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manga root {dir or zip file}
|
||||
* |--- index.json (optional)
|
||||
* |--- Page 1.png
|
||||
* |--- Page 2.png
|
||||
* |---Chapter 1/(dir or zip, optional)
|
||||
* |------Page 1.1.png
|
||||
* :
|
||||
* L--- Page x.png
|
||||
*/
|
||||
class LocalMangaParser(private val uri: Uri) {
|
||||
|
||||
constructor(file: File) : this(file.toUri())
|
||||
|
||||
private val rootFile: File = File(uri.schemeSpecificPart)
|
||||
|
||||
suspend fun getManga(withDetails: Boolean): LocalManga = runInterruptible(Dispatchers.IO) {
|
||||
val (fileSystem, rootPath) = uri.resolveFsAndPath()
|
||||
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
||||
val mangaInfo = index?.getMangaInfo()
|
||||
if (mangaInfo != null) {
|
||||
val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it } ?: fileSystem.findFirstImage(rootPath)
|
||||
mangaInfo.copyInternal(
|
||||
source = LocalMangaSource,
|
||||
url = rootFile.toUri().toString(),
|
||||
coverUrl = coverEntry?.let { uri.child(it, resolve = true).toString() }.orEmpty(),
|
||||
largeCoverUrl = null,
|
||||
chapters = if (withDetails) {
|
||||
mangaInfo.chapters?.map { c ->
|
||||
c.copyInternal(
|
||||
url = index.getChapterFileName(c.id)?.toPath()?.let {
|
||||
uri.child(it, resolve = false).toString()
|
||||
} ?: uri.toString(),
|
||||
source = LocalMangaSource,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
} else {
|
||||
val title = rootFile.nameWithoutExtension.replace("_", " ").toCamelCase()
|
||||
val coverEntry = fileSystem.findFirstImage(rootPath)
|
||||
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||
Manga(
|
||||
id = rootFile.absolutePath.longHashCode(),
|
||||
title = title,
|
||||
url = rootFile.toUri().toString(),
|
||||
publicUrl = rootFile.toUri().toString(),
|
||||
source = LocalMangaSource,
|
||||
coverUrl = coverEntry?.let {
|
||||
uri.child(it, resolve = true).toString()
|
||||
}.orEmpty(),
|
||||
chapters = if (withDetails) {
|
||||
val chapters = fileSystem.listRecursively(rootPath)
|
||||
.mapNotNullTo(HashSet()) { path ->
|
||||
if (path != coverEntry && fileSystem.isRegularFile(path) && mimeTypeMap.isImage(path)) {
|
||||
path.parent
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedWith(compareBy(AlphanumComparator()) { x -> x.toString() })
|
||||
chapters.mapIndexed { i, p ->
|
||||
val s = if (p.root == rootPath.root) {
|
||||
p.relativeTo(rootPath).toString()
|
||||
} else {
|
||||
p
|
||||
}.toString().removePrefix(Path.DIRECTORY_SEPARATOR)
|
||||
MangaChapter(
|
||||
id = "$i$s".longHashCode(),
|
||||
name = s.ifEmpty { title },
|
||||
number = 0f,
|
||||
volume = 0,
|
||||
source = LocalMangaSource,
|
||||
uploadDate = 0L,
|
||||
url = uri.child(p.relativeTo(rootPath), resolve = false).toString(),
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
altTitle = null,
|
||||
rating = -1f,
|
||||
isNsfw = false,
|
||||
tags = setOf(),
|
||||
state = null,
|
||||
author = null,
|
||||
largeCoverUrl = null,
|
||||
description = null,
|
||||
)
|
||||
}.let { LocalManga(it, rootFile) }
|
||||
}
|
||||
|
||||
suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
||||
val (fileSystem, rootPath) = uri.resolveFsAndPath()
|
||||
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
||||
index?.getMangaInfo()
|
||||
}
|
||||
|
||||
suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||
val chapterUri = chapter.url.toUri().resolve()
|
||||
val (fileSystem, rootPath) = chapterUri.resolveFsAndPath()
|
||||
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
||||
val entries = fileSystem.listRecursively(rootPath)
|
||||
.filter { fileSystem.isRegularFile(it) }
|
||||
if (index != null) {
|
||||
val pattern = index.getChapterNamesPattern(chapter)
|
||||
entries.filter { x -> x.name.substringBefore('.').matches(pattern) }
|
||||
} else {
|
||||
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||
entries.filter { x ->
|
||||
mimeTypeMap.isImage(x) && x.parent == rootPath
|
||||
}
|
||||
}.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() })
|
||||
.map { x ->
|
||||
val entryUri = chapterUri.child(x, resolve = true).toString()
|
||||
MangaPage(
|
||||
id = entryUri.longHashCode(),
|
||||
url = entryUri,
|
||||
preview = null,
|
||||
source = LocalMangaSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Uri.child(path: Path, resolve: Boolean): Uri {
|
||||
val builder = buildUpon()
|
||||
if (isZipUri() || !resolve) {
|
||||
builder.fragment(path.toString().removePrefix(Path.DIRECTORY_SEPARATOR))
|
||||
} else {
|
||||
val file = toFile()
|
||||
if (file.isZipArchive) {
|
||||
builder.fragment(path.toString().removePrefix(Path.DIRECTORY_SEPARATOR))
|
||||
builder.scheme(URI_SCHEME_ZIP)
|
||||
} else {
|
||||
builder.appendEncodedPath(path.relativeTo(file.toOkioPath()).toString())
|
||||
}
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@Blocking
|
||||
fun getOrNull(file: File): LocalMangaParser? = if ((file.isDirectory || file.isZipArchive) && file.canRead()) {
|
||||
LocalMangaParser(file)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun find(roots: Iterable<File>, manga: Manga): LocalMangaParser? = channelFlow {
|
||||
val fileName = manga.title.toFileNameSafe()
|
||||
for (root in roots) {
|
||||
launch {
|
||||
val parser = getOrNull(File(root, fileName)) ?: getOrNull(File(root, "$fileName.cbz"))
|
||||
val info = runCatchingCancellable { parser?.getMangaInfo() }.getOrNull()
|
||||
if (info?.id == manga.id) {
|
||||
send(parser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.Default).firstOrNull()
|
||||
|
||||
private fun FileSystem.findFirstImage(rootPath: Path) = findFirstImageImpl(rootPath, false)
|
||||
?: findFirstImageImpl(rootPath, true)
|
||||
|
||||
private fun FileSystem.findFirstImageImpl(
|
||||
rootPath: Path,
|
||||
recursive: Boolean
|
||||
): Path? = runCatchingCancellable {
|
||||
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||
if (recursive) {
|
||||
listRecursively(rootPath)
|
||||
} else {
|
||||
list(rootPath).asSequence()
|
||||
}.filter { isRegularFile(it) && mimeTypeMap.isImage(it) }
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() })
|
||||
.firstOrNull()
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
private fun MimeTypeMap.isImage(path: Path): Boolean =
|
||||
getMimeTypeFromExtension(path.name.substringAfterLast('.'))
|
||||
?.startsWith("image/") == true
|
||||
|
||||
private fun Uri.resolve(): Uri = if (isFileUri()) {
|
||||
val file = toFile()
|
||||
if (file.isZipArchive) {
|
||||
this
|
||||
} else if (file.isDirectory) {
|
||||
file.resolve(fragment.orEmpty()).toUri()
|
||||
} else {
|
||||
this
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@Blocking
|
||||
private fun Uri.resolveFsAndPath(): Pair<FileSystem, Path> {
|
||||
val resolved = resolve()
|
||||
return when {
|
||||
resolved.isZipUri() -> {
|
||||
FileSystem.SYSTEM.openZip(resolved.schemeSpecificPart.toPath()) to resolved.fragment.orEmpty()
|
||||
.toRootedPath()
|
||||
}
|
||||
|
||||
isFileUri() -> {
|
||||
val file = toFile()
|
||||
if (file.isZipArchive) {
|
||||
FileSystem.SYSTEM.openZip(schemeSpecificPart.toPath()) to fragment.orEmpty().toRootedPath()
|
||||
} else {
|
||||
FileSystem.SYSTEM to file.toOkioPath()
|
||||
}
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Unsupported uri $resolved")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toRootedPath(): Path = if (startsWith(Path.DIRECTORY_SEPARATOR)) {
|
||||
this
|
||||
} else {
|
||||
Path.DIRECTORY_SEPARATOR + this
|
||||
}.toPath()
|
||||
|
||||
private fun Manga.copyInternal(
|
||||
url: String = this.url,
|
||||
coverUrl: String = this.coverUrl,
|
||||
largeCoverUrl: String? = this.largeCoverUrl,
|
||||
chapters: List<MangaChapter>? = this.chapters,
|
||||
source: MangaSource = this.source,
|
||||
): Manga = Manga(
|
||||
id = id,
|
||||
title = title,
|
||||
altTitle = altTitle,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
coverUrl = coverUrl,
|
||||
tags = tags,
|
||||
state = state,
|
||||
author = author,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
description = description,
|
||||
chapters = chapters,
|
||||
source = source,
|
||||
)
|
||||
|
||||
private fun MangaChapter.copyInternal(
|
||||
url: String = this.url,
|
||||
source: MangaSource = this.source,
|
||||
) = MangaChapter(
|
||||
id = id,
|
||||
name = name,
|
||||
number = number,
|
||||
volume = volume,
|
||||
url = url,
|
||||
scanlator = scanlator,
|
||||
uploadDate = uploadDate,
|
||||
branch = branch,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.data.input
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||
import org.koitharu.kotatsu.core.util.ext.readText
|
||||
import org.koitharu.kotatsu.core.util.ext.toListSorted
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||
import java.io.File
|
||||
import java.util.Enumeration
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* Manga archive {.cbz or .zip file}
|
||||
* |--- index.json (optional)
|
||||
* |--- Page 1.png
|
||||
* |--- Page 2.png
|
||||
* :
|
||||
* L--- Page x.png
|
||||
*/
|
||||
class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
||||
|
||||
override suspend fun getManga(): LocalManga {
|
||||
val manga = runInterruptible(Dispatchers.IO) {
|
||||
ZipFile(root).use { zip ->
|
||||
val fileUri = root.toUri().toString()
|
||||
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
|
||||
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||
val info = index?.getMangaInfo()
|
||||
if (info != null) {
|
||||
val cover = zipUri(
|
||||
root,
|
||||
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
|
||||
)
|
||||
return@use info.copy2(
|
||||
source = LocalMangaSource,
|
||||
url = fileUri,
|
||||
coverUrl = cover,
|
||||
largeCoverUrl = cover,
|
||||
chapters = info.chapters?.map { c ->
|
||||
c.copy(url = fileUri, source = LocalMangaSource)
|
||||
},
|
||||
)
|
||||
}
|
||||
// fallback
|
||||
val title = root.nameWithoutExtension.replace("_", " ").toCamelCase()
|
||||
val chapters = ArraySet<String>()
|
||||
for (x in zip.entries()) {
|
||||
if (!x.isDirectory) {
|
||||
chapters += x.name.substringBeforeLast(File.separatorChar, "")
|
||||
}
|
||||
}
|
||||
val uriBuilder = root.toUri().buildUpon()
|
||||
Manga(
|
||||
id = root.absolutePath.longHashCode(),
|
||||
title = title,
|
||||
url = fileUri,
|
||||
publicUrl = fileUri,
|
||||
source = LocalMangaSource,
|
||||
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
|
||||
chapters = chapters.sortedWith(AlphanumComparator())
|
||||
.mapIndexed { i, s ->
|
||||
MangaChapter(
|
||||
id = "$i$s".longHashCode(),
|
||||
name = s.ifEmpty { title },
|
||||
number = 0f,
|
||||
volume = 0,
|
||||
source = LocalMangaSource,
|
||||
uploadDate = 0L,
|
||||
url = uriBuilder.fragment(s).build().toString(),
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
},
|
||||
altTitle = null,
|
||||
rating = -1f,
|
||||
isNsfw = false,
|
||||
tags = setOf(),
|
||||
state = null,
|
||||
author = null,
|
||||
largeCoverUrl = null,
|
||||
description = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
return LocalManga(manga, root)
|
||||
}
|
||||
|
||||
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
||||
ZipFile(root).use { zip ->
|
||||
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
|
||||
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||
index?.getMangaInfo()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
return runInterruptible(Dispatchers.IO) {
|
||||
val uri = Uri.parse(chapter.url)
|
||||
val file = uri.toFile()
|
||||
ZipFile(file).use { zip ->
|
||||
val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
|
||||
var entries = zip.entries().asSequence()
|
||||
entries = if (index != null) {
|
||||
val pattern = index.getChapterNamesPattern(chapter)
|
||||
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
|
||||
} else {
|
||||
val parent = uri.fragment.orEmpty()
|
||||
entries.filter { x ->
|
||||
!x.isDirectory && x.name.substringBeforeLast(
|
||||
File.separatorChar,
|
||||
"",
|
||||
) == parent
|
||||
}
|
||||
}
|
||||
entries
|
||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
.map { x ->
|
||||
val entryUri = zipUri(file, x.name)
|
||||
MangaPage(
|
||||
id = entryUri.longHashCode(),
|
||||
url = entryUri,
|
||||
preview = null,
|
||||
source = LocalMangaSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
|
||||
val list = entries.toList()
|
||||
.filterNot { it.isDirectory }
|
||||
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
return list.firstOrNull {
|
||||
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
|
||||
?.startsWith("image/") == true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaDirInput
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||
@@ -96,7 +96,7 @@ class LocalMangaDirOutput(
|
||||
}
|
||||
|
||||
suspend fun deleteChapters(ids: Set<Long>) = mutex.withLock {
|
||||
val chapters = checkNotNull((index.getMangaInfo() ?: LocalMangaDirInput(rootFile).getManga().manga).chapters) {
|
||||
val chapters = checkNotNull((index.getMangaInfo() ?: LocalMangaParser(rootFile).getManga(withDetails = true).manga).chapters) {
|
||||
"No chapters found"
|
||||
}.withIndex()
|
||||
val victimsIds = ids.toMutableSet()
|
||||
|
||||
@@ -7,7 +7,7 @@ import kotlinx.coroutines.withContext
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.core.prefs.DownloadFormat
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
@@ -100,7 +100,7 @@ sealed class LocalMangaOutput(
|
||||
|
||||
private suspend fun canWriteTo(file: File, manga: Manga): Boolean {
|
||||
val info = runCatchingCancellable {
|
||||
LocalMangaInput.of(file).getMangaInfo()
|
||||
LocalMangaParser(file).getMangaInfo()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull() ?: return false
|
||||
|
||||
@@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.fold
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.model.ids
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
@@ -18,6 +17,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.findById
|
||||
import org.koitharu.kotatsu.parsers.util.recoverCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
@@ -77,8 +77,8 @@ class DeleteReadChaptersUseCase @Inject constructor(
|
||||
return null
|
||||
}
|
||||
val branch = (chapters.findById(history.chapterId) ?: return null).branch
|
||||
val filteredChapters = manga.manga.getChapters(branch)?.takeWhile { it.id != history.chapterId }
|
||||
return if (filteredChapters.isNullOrEmpty()) {
|
||||
val filteredChapters = chapters.filter { x -> x.branch == branch }.takeWhile { it.id != history.chapterId }
|
||||
return if (filteredChapters.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
DeletionTask(
|
||||
|
||||
@@ -29,7 +29,7 @@ abstract class LocalObserveMapper<E : Any, R : Any>(
|
||||
val mapped = if (m.isLocal) {
|
||||
m
|
||||
} else {
|
||||
localMangaIndex.get(m.id)?.manga
|
||||
localMangaIndex.get(m.id, withDetails = false)?.manga
|
||||
}
|
||||
mapped?.let { mm -> toResult(item, mm) }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.local.domain.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import org.koitharu.kotatsu.core.util.ext.creationTime
|
||||
@@ -21,6 +22,8 @@ data class LocalManga(
|
||||
return field
|
||||
}
|
||||
|
||||
fun toUri(): Uri = manga.url.toUri()
|
||||
|
||||
fun isMatchesQuery(query: String): Boolean {
|
||||
return manga.title.contains(query, ignoreCase = true) ||
|
||||
manga.altTitle?.contains(query, ignoreCase = true) == true ||
|
||||
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
@@ -48,23 +47,19 @@ class ImportService : CoroutineIntentService() {
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
}
|
||||
|
||||
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
val uri = requireNotNull(intent.getStringExtra(DATA_URI)?.toUriOrNull()) { "No input uri" }
|
||||
startForeground()
|
||||
try {
|
||||
val result = runCatchingCancellable {
|
||||
importer.import(uri).manga
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
} finally {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
startForeground(this)
|
||||
val result = runCatchingCancellable {
|
||||
importer.import(uri).manga
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) {
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
@@ -72,7 +67,7 @@ class ImportService : CoroutineIntentService() {
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground() {
|
||||
private fun startForeground(jobContext: IntentJobContext) {
|
||||
val title = applicationContext.getString(R.string.importing_manga)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(title)
|
||||
@@ -95,8 +90,7 @@ class ImportService : CoroutineIntentService() {
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.build()
|
||||
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
jobContext.setForeground(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationManager
|
||||
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.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -42,21 +43,17 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
val manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return
|
||||
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
|
||||
startForeground()
|
||||
try {
|
||||
val mangaWithChapters = localMangaRepository.getDetails(manga)
|
||||
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
|
||||
localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga)))
|
||||
} finally {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
startForeground(this)
|
||||
val mangaWithChapters = localMangaRepository.getDetails(manga)
|
||||
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
|
||||
localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga)))
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) {
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.error_occurred))
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(0)
|
||||
@@ -64,13 +61,14 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||
.setContentText(error.getDisplayMessage(resources))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(ErrorReporterReceiver.getPendingIntent(this, error))
|
||||
.setContentIntent(ErrorReporterReceiver.getPendingIntent(applicationContext, error))
|
||||
.build()
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID + startId, notification)
|
||||
}
|
||||
|
||||
private fun startForeground() {
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground(jobContext: IntentJobContext) {
|
||||
val title = getString(R.string.local_manga_processing)
|
||||
val manager = NotificationManagerCompat.from(this)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
@@ -92,7 +90,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||
.setOngoing(false)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
jobContext.setForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -12,9 +12,9 @@ class LocalIndexUpdateService : CoroutineIntentService() {
|
||||
@Inject
|
||||
lateinit var localMangaIndex: LocalMangaIndex
|
||||
|
||||
override suspend fun processIntent(startId: Int, intent: Intent) {
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
localMangaIndex.update()
|
||||
}
|
||||
|
||||
override fun onError(startId: Int, error: Throwable) = Unit
|
||||
override fun IntentJobContext.onError(error: Throwable) = Unit
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick))
|
||||
addMenuProvider(LocalListMenuProvider(this, this::onEmptyActionClick))
|
||||
addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel))
|
||||
viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package org.koitharu.kotatsu.local.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
|
||||
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
|
||||
|
||||
class LocalListMenuProvider(
|
||||
private val context: Context,
|
||||
private val fragment: Fragment,
|
||||
private val onImportClick: Function0<Unit>,
|
||||
) : MenuProvider {
|
||||
|
||||
@@ -17,6 +19,11 @@ class LocalListMenuProvider(
|
||||
menuInflater.inflate(R.menu.opt_local, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_filter)?.isVisible = FilterSheetFragment.isSupported(fragment)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return when (menuItem.itemId) {
|
||||
R.id.action_import -> {
|
||||
@@ -25,7 +32,14 @@ class LocalListMenuProvider(
|
||||
}
|
||||
|
||||
R.id.action_directories -> {
|
||||
context.startActivity(MangaDirectoriesActivity.newIntent(context))
|
||||
fragment.context?.run {
|
||||
startActivity(Intent(this, MangaDirectoriesActivity::class.java))
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_filter -> {
|
||||
FilterSheetFragment.show(fragment.childFragmentManager)
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,10 @@ package org.koitharu.kotatsu.main.domain
|
||||
|
||||
import androidx.collection.ArraySet
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.network.HttpException
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageResult
|
||||
import okio.FileNotFoundException
|
||||
import org.jsoup.HttpStatusException
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
@@ -17,13 +13,11 @@ import org.koitharu.kotatsu.core.util.ext.bookmarkKey
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaKey
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.findById
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.net.UnknownHostException
|
||||
import java.util.Collections
|
||||
import javax.inject.Inject
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
class CoverRestoreInterceptor @Inject constructor(
|
||||
private val dataRepository: MangaDataRepository,
|
||||
@@ -118,11 +112,6 @@ class CoverRestoreInterceptor @Inject constructor(
|
||||
}
|
||||
|
||||
private fun Throwable.shouldRestore(): Boolean {
|
||||
return this is HttpException
|
||||
|| this is HttpStatusException
|
||||
|| this is SSLException
|
||||
|| this is ParseException
|
||||
|| this is UnknownHostException
|
||||
|| this is FileNotFoundException
|
||||
return this is Exception // any Exception but not Error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.about.AppUpdateActivity
|
||||
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -353,6 +354,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
requestNotificationsPermission()
|
||||
}
|
||||
startService(Intent(this@MainActivity, LocalIndexUpdateService::class.java))
|
||||
startService(Intent(this@MainActivity, PeriodicalBackupService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -71,8 +72,8 @@ class ChaptersLoader @Inject constructor(
|
||||
return chapterId in chapterPages
|
||||
}
|
||||
|
||||
fun getPages(chapterId: Long): List<ReaderPage> {
|
||||
return chapterPages.subList(chapterId)
|
||||
fun getPages(chapterId: Long): List<MangaPage> = synchronized(chapterPages) {
|
||||
return chapterPages.subList(chapterId).map { it.toMangaPage() }
|
||||
}
|
||||
|
||||
fun getPagesCount(chapterId: Long): Int {
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.core.net.toFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.core.model.findChapter
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
@@ -40,7 +39,7 @@ class DetectReaderModeUseCase @Inject constructor(
|
||||
if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) {
|
||||
return defaultMode
|
||||
}
|
||||
val chapter = state?.let { manga.findChapter(it.chapterId) }
|
||||
val chapter = state?.let { manga.findChapterById(it.chapterId) }
|
||||
?: manga.chapters?.firstOrNull()
|
||||
?: error("There are no chapters in this manga")
|
||||
val repo = mangaRepositoryFactory.create(manga.source)
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.graphics.Color
|
||||
import android.graphics.Point
|
||||
import android.graphics.Rect
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.collection.LruCache
|
||||
import androidx.core.graphics.alpha
|
||||
import androidx.core.graphics.blue
|
||||
import androidx.core.graphics.get
|
||||
@@ -28,38 +29,46 @@ import kotlin.math.abs
|
||||
class EdgeDetector(private val context: Context) {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val cache = LruCache<ImageSource, Rect>(CACHE_SIZE)
|
||||
|
||||
suspend fun getBounds(imageSource: ImageSource): Rect? = mutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val decoder = SkiaPooledImageRegionDecoder(Bitmap.Config.RGB_565)
|
||||
try {
|
||||
val size = runInterruptible {
|
||||
decoder.init(context, imageSource)
|
||||
}
|
||||
val edges = coroutineScope {
|
||||
listOf(
|
||||
async { detectLeftRightEdge(decoder, size, isLeft = true) },
|
||||
async { detectTopBottomEdge(decoder, size, isTop = true) },
|
||||
async { detectLeftRightEdge(decoder, size, isLeft = false) },
|
||||
async { detectTopBottomEdge(decoder, size, isTop = false) },
|
||||
).awaitAll()
|
||||
}
|
||||
var hasEdges = false
|
||||
for (edge in edges) {
|
||||
if (edge > 0) {
|
||||
hasEdges = true
|
||||
} else if (edge < 0) {
|
||||
return@withContext null
|
||||
suspend fun getBounds(imageSource: ImageSource): Rect? {
|
||||
cache[imageSource]?.let { rect ->
|
||||
return if (rect.isEmpty) null else rect
|
||||
}
|
||||
return mutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val decoder = SkiaPooledImageRegionDecoder(Bitmap.Config.RGB_565)
|
||||
try {
|
||||
val size = runInterruptible {
|
||||
decoder.init(context, imageSource)
|
||||
}
|
||||
val edges = coroutineScope {
|
||||
listOf(
|
||||
async { detectLeftRightEdge(decoder, size, isLeft = true) },
|
||||
async { detectTopBottomEdge(decoder, size, isTop = true) },
|
||||
async { detectLeftRightEdge(decoder, size, isLeft = false) },
|
||||
async { detectTopBottomEdge(decoder, size, isTop = false) },
|
||||
).awaitAll()
|
||||
}
|
||||
var hasEdges = false
|
||||
for (edge in edges) {
|
||||
if (edge > 0) {
|
||||
hasEdges = true
|
||||
} else if (edge < 0) {
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
if (hasEdges) {
|
||||
Rect(edges[0], edges[1], size.x - edges[2], size.y - edges[3])
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} finally {
|
||||
decoder.recycle()
|
||||
}
|
||||
if (hasEdges) {
|
||||
Rect(edges[0], edges[1], size.x - edges[2], size.y - edges[3])
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} finally {
|
||||
decoder.recycle()
|
||||
}
|
||||
}.also {
|
||||
cache.put(imageSource, it ?: EMPTY_RECT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +144,8 @@ class EdgeDetector(private val context: Context) {
|
||||
|
||||
private const val BLOCK_SIZE = 100
|
||||
private const val COLOR_TOLERANCE = 16
|
||||
private const val CACHE_SIZE = 24
|
||||
private val EMPTY_RECT = Rect(0, 0, 0, 0)
|
||||
|
||||
fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int, tolerance: Int): Boolean {
|
||||
return abs(a.red - b.red) <= tolerance &&
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user