diff --git a/README.md b/README.md index 756ce75ec..e0236e062 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ Kotatsu is a free and open-source manga reader for Android with built-in online content sources. -[![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE) +[![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) ![F-Droid Version](https://img.shields.io/f-droid/v/org.koitharu.kotatsu) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](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 diff --git a/app/build.gradle b/app/build.gradle index 0ba441866..4b357db55 100644 --- a/app/build.gradle +++ b/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 = 674 - versionName = '7.6.1' + versionCode = 686 + versionName = '7.7-a7' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -37,11 +39,23 @@ android { shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } + nightly { + initWith release + applicationIdSuffix = '.nightly' + } } buildFeatures { viewBinding true buildConfig true } + packagingOptions { + resources { + excludes += [ + 'META-INF/README.md', + 'META-INF/NOTICE.md' + ] + } + } sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) main.java.srcDirs += 'src/main/kotlin/' @@ -59,12 +73,12 @@ android { '-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi', '-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlin.contracts.ExperimentalContracts', - '-opt-in=coil.annotation.ExperimentalCoilApi', + '-opt-in=coil3.annotation.ExperimentalCoilApi', ] } lint { abortOnError true - disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', '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,87 +105,92 @@ afterEvaluate { } } dependencies { - //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:1.1') { - exclude group: 'org.json', module: 'json' + 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') } - - 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' - - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'androidx.core:core-ktx:1.13.1' - implementation 'androidx.activity:activity-ktx:1.9.2' - implementation 'androidx.fragment:fragment-ktx:1.8.3' - 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 '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' + //noinspection UseTomlInstead + implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") { + exclude group: 'org.json', module: 'json' } - 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 '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 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' - implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' - - 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 'io.coil-kt:coil-base:2.7.0' - implementation 'io.coil-kt:coil-svg:2.7.0' - implementation 'com.github.KotatsuApp:subsampling-scale-image-view:b2c5a6d5ca' - implementation 'com.github.solkin:disk-lru-cache:1.4' - implementation 'io.noties.markwon:core:4.6.2' - - implementation 'ch.acra:acra-http:5.11.4' - implementation 'ch.acra:acra-dialog:5.11.4' - - implementation 'org.conscrypt:conscrypt-android:2.5.2' - - debugImplementation 'com.squareup.leakcanary:leakcanary-android:3.0-alpha-8' - debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747' - - testImplementation 'junit:junit:4.13.2' - testImplementation 'org.json:json:20240303' - testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0' - - 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 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0' - - androidTestImplementation 'androidx.room:room-testing:2.6.1' - androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1' - - androidTestImplementation 'com.google.dagger:hilt-android-testing:2.52' - kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.52' + coreLibraryDesugaring libs.desugar.jdk.libs + implementation libs.kotlin.stdlib + implementation libs.kotlinx.coroutines.android + implementation libs.kotlinx.coroutines.guava + + implementation libs.appcompat + implementation libs.core.ktx + implementation libs.activity.ktx + implementation libs.fragment.ktx + implementation libs.transition.ktx + implementation libs.collection.ktx + implementation libs.lifecycle.viewmodel.ktx + 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.ktx + implementation libs.androidx.biometric.ktx + implementation libs.material + implementation libs.androidx.lifecycle.common.java8 + implementation libs.androidx.webkit + + implementation libs.androidx.work.runtime + implementation libs.guava + + implementation libs.androidx.room.runtime + implementation libs.androidx.room.ktx + ksp libs.androidx.room.compiler + + implementation libs.okhttp + implementation libs.okhttp.tls + implementation libs.okhttp.dnsoverhttps + implementation libs.okio + + implementation libs.adapterdelegates4.kotlin.dsl + implementation libs.adapterdelegates4.kotlin.dsl.viewbinding + + implementation libs.hilt.android + kapt libs.hilt.compiler + implementation libs.androidx.hilt.work + kapt libs.androidx.hilt.compiler + + implementation libs.coil.core + implementation libs.coil.network + implementation libs.coil.gif + implementation libs.coil.svg + implementation libs.avif.decoder + implementation libs.subsampling.scale.image.view + implementation libs.disk.lru.cache + implementation libs.core + + implementation libs.acra.http + implementation libs.acra.dialog + + implementation libs.conscrypt.android + + debugImplementation libs.leakcanary.android + debugImplementation libs.workinspector + + testImplementation libs.junit + testImplementation libs.json + testImplementation libs.kotlinx.coroutines.test + + androidTestImplementation libs.androidx.runner + androidTestImplementation libs.androidx.rules + androidTestImplementation libs.androidx.core.ktx + androidTestImplementation libs.androidx.junit.ktx + + androidTestImplementation libs.kotlinx.coroutines.test + + androidTestImplementation libs.androidx.room.testing + androidTestImplementation libs.moshi.kotlin + + androidTestImplementation libs.hilt.android.testing + kaptAndroidTest libs.hilt.android.compiler } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 33f424b19..79c61d6e5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt index eec41000c..1e9dd8a44 100644 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu import android.content.Context +import android.os.Build import android.os.StrictMode import androidx.fragment.app.strictmode.FragmentStrictMode import org.koitharu.kotatsu.core.BaseApp @@ -18,30 +19,55 @@ class KotatsuApp : BaseApp() { } private fun enableStrictMode() { + val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + StrictModeNotifier(this) + } else { + null + } StrictMode.setThreadPolicy( - StrictMode.ThreadPolicy.Builder() - .detectAll() - .penaltyLog() - .build(), + StrictMode.ThreadPolicy.Builder().apply { + detectNetwork() + detectDiskWrites() + detectCustomSlowCalls() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc() + penaltyLog() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) { + penaltyListener(notifier.executor, notifier) + } + }.build(), ) StrictMode.setVmPolicy( - StrictMode.VmPolicy.Builder() - .detectAll() - .setClassInstanceLimit(LocalMangaRepository::class.java, 1) - .setClassInstanceLimit(PagesCache::class.java, 1) - .setClassInstanceLimit(MangaLoaderContext::class.java, 1) - .setClassInstanceLimit(PageLoader::class.java, 1) - .setClassInstanceLimit(ReaderViewModel::class.java, 1) - .penaltyLog() - .build(), + StrictMode.VmPolicy.Builder().apply { + detectActivityLeaks() + detectLeakedSqlLiteObjects() + detectLeakedClosableObjects() + detectLeakedRegistrationObjects() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission() + detectFileUriExposure() + setClassInstanceLimit(LocalMangaRepository::class.java, 1) + setClassInstanceLimit(PagesCache::class.java, 1) + setClassInstanceLimit(MangaLoaderContext::class.java, 1) + setClassInstanceLimit(PageLoader::class.java, 1) + setClassInstanceLimit(ReaderViewModel::class.java, 1) + penaltyLog() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) { + penaltyListener(notifier.executor, notifier) + } + }.build() ) - FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder() - .penaltyDeath() - .detectFragmentReuse() - .detectWrongFragmentContainer() - .detectRetainInstanceUsage() - .detectSetUserVisibleHint() - .detectFragmentTagUsage() - .build() + FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply { + detectWrongFragmentContainer() + detectFragmentTagUsage() + detectRetainInstanceUsage() + detectSetUserVisibleHint() + detectWrongNestedHierarchy() + detectFragmentReuse() + penaltyLog() + if (notifier != null) { + penaltyListener(notifier) + } + }.build() } } diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/StrictModeNotifier.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/StrictModeNotifier.kt new file mode 100644 index 000000000..058e53c09 --- /dev/null +++ b/app/src/debug/kotlin/org/koitharu/kotatsu/StrictModeNotifier.kt @@ -0,0 +1,73 @@ +package org.koitharu.kotatsu + +import android.app.Notification +import android.app.Notification.BigTextStyle +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.os.StrictMode +import android.os.strictmode.Violation +import androidx.annotation.RequiresApi +import androidx.core.app.PendingIntentCompat +import androidx.core.content.getSystemService +import androidx.fragment.app.strictmode.FragmentStrictMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import org.koitharu.kotatsu.core.util.ShareHelper +import kotlin.math.absoluteValue +import androidx.fragment.app.strictmode.Violation as FragmentViolation + +@RequiresApi(Build.VERSION_CODES.P) +class StrictModeNotifier( + private val context: Context, +) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener { + + val executor = Dispatchers.Default.asExecutor() + + private val notificationManager by lazy { + val nm = checkNotNull(context.getSystemService()) + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.strict_mode), + NotificationManager.IMPORTANCE_LOW, + ) + nm.createNotificationChannel(channel) + nm + } + + override fun onVmViolation(v: Violation) = showNotification(v) + + override fun onThreadViolation(v: Violation) = showNotification(v) + + override fun onViolation(violation: FragmentViolation) = showNotification(violation) + + private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_bug) + .setContentTitle(context.getString(R.string.strict_mode)) + .setContentText(violation.message) + .setStyle( + BigTextStyle() + .setBigContentTitle(context.getString(R.string.strict_mode)) + .setSummaryText(violation.message) + .bigText(violation.stackTraceToString()), + ).setShowWhen(true) + .setContentIntent( + PendingIntentCompat.getActivity( + context, + 0, + ShareHelper(context).getShareTextIntent(violation.stackTraceToString()), + 0, + false, + ), + ) + .setAutoCancel(true) + .setGroup(CHANNEL_ID) + .build() + .let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) } + + private companion object { + + const val CHANNEL_ID = "strict_mode" + } +} diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt index 3a550ec35..fc3780585 100644 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt +++ b/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt @@ -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) diff --git a/app/src/debug/res/drawable-anydpi-v24/ic_bug.xml b/app/src/debug/res/drawable-anydpi-v24/ic_bug.xml new file mode 100644 index 000000000..dc7fd8955 --- /dev/null +++ b/app/src/debug/res/drawable-anydpi-v24/ic_bug.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/debug/res/drawable-hdpi/ic_bug.png b/app/src/debug/res/drawable-hdpi/ic_bug.png new file mode 100644 index 000000000..b4aa5f3c7 Binary files /dev/null and b/app/src/debug/res/drawable-hdpi/ic_bug.png differ diff --git a/app/src/debug/res/drawable-mdpi/ic_bug.png b/app/src/debug/res/drawable-mdpi/ic_bug.png new file mode 100644 index 000000000..726da962c Binary files /dev/null and b/app/src/debug/res/drawable-mdpi/ic_bug.png differ diff --git a/app/src/debug/res/drawable-xhdpi/ic_bug.png b/app/src/debug/res/drawable-xhdpi/ic_bug.png new file mode 100644 index 000000000..f71348751 Binary files /dev/null and b/app/src/debug/res/drawable-xhdpi/ic_bug.png differ diff --git a/app/src/debug/res/drawable-xxhdpi/ic_bug.png b/app/src/debug/res/drawable-xxhdpi/ic_bug.png new file mode 100644 index 000000000..99ce95508 Binary files /dev/null and b/app/src/debug/res/drawable-xxhdpi/ic_bug.png differ diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index 5a9c5bfd8..dfda02b0e 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,3 +1,4 @@ Kotatsu Dev - \ No newline at end of file + Strict mode + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd7988db3..97db999be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -266,19 +266,26 @@ tools:node="merge" /> + android:foregroundServiceType="dataSync" + android:label="@string/local_manga_processing" /> + android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService" + android:foregroundServiceType="dataSync" + android:label="@string/periodic_backups" /> - + android:foregroundServiceType="dataSync" + android:label="@string/fixing_manga" /> + + android:exported="false" + android:label="@string/prefetch_content" /> + notification.addAction( R.drawable.ic_alert_outline, applicationContext.getString(R.string.report), - ErrorReporterReceiver.getPendingIntent(applicationContext, error), + reportIntent, ) + } } return notification.build() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt index ad092b746..acc4c74f6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt @@ -17,9 +17,6 @@ data class Bookmark( val percent: Float, ) : ListModel { - val directImageUrl: String? - get() = if (isImageUrlDirect()) imageUrl else null - val imageLoadData: Any get() = if (isImageUrlDirect()) imageUrl else toMangaPage() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt index 3a0182c70..5670eb82a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt @@ -14,7 +14,7 @@ import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkLargeAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkLargeAD.kt index 00c0f6a2e..77fa1e9d8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkLargeAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkLargeAD.kt @@ -1,17 +1,18 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.bookmarkExtra import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding import org.koitharu.kotatsu.list.ui.model.ListModel @@ -29,9 +30,8 @@ fun bookmarkLargeAD( size(CoverSizeResolver(binding.imageViewThumb)) defaultPlaceholders(context) allowRgb565(true) - tag(item) + bookmarkExtra(item) decodeRegion(item.scroll) - source(item.manga.source) enqueueWith(coil) } binding.progressView.setProgress(item.percent, false) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt index 065bf84dc..d92f8a2da 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt @@ -1,19 +1,21 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.bookmarkExtra import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemBookmarkBinding +// TODO check usages fun bookmarkListAD( coil: ImageLoader, lifecycleOwner: LifecycleOwner, @@ -28,9 +30,8 @@ fun bookmarkListAD( size(CoverSizeResolver(binding.imageViewThumb)) defaultPlaceholders(context) allowRgb565(true) - tag(item) + bookmarkExtra(item) decodeRegion(item.scroll) - source(item.manga.source) enqueueWith(coil) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt index fcc980fd4..6bdee400c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter import android.content.Context import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt index 1da50cefb..159550134 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt @@ -12,7 +12,6 @@ import androidx.core.graphics.Insets import androidx.core.view.isVisible import androidx.core.view.updatePadding import dagger.hilt.android.AndroidEntryPoint -import okhttp3.internal.userAgent import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.network.CommonHeaders @@ -45,7 +44,7 @@ class BrowserActivity : BaseActivity(), BrowserCallback } val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE)) val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository - repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) + val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) viewBinding.webView.configureForParser(userAgent) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) viewBinding.webView.webViewClient = BrowserClient(this) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt index 83318f8ea..33439e8b0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt @@ -9,9 +9,10 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.net.toUri -import coil.EventListener -import coil.request.ErrorResult -import coil.request.ImageRequest +import coil3.EventListener +import coil3.Extras +import coil3.request.ErrorResult +import coil3.request.ImageRequest import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.getTitle @@ -21,14 +22,14 @@ import org.koitharu.kotatsu.parsers.model.MangaSource class CaptchaNotifier( private val context: Context, -) : EventListener { +) : EventListener() { fun notify(exception: CloudFlareProtectedException) { if (!context.checkNotificationPermission(CHANNEL_ID)) { return } val manager = NotificationManagerCompat.from(context) - val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) .setName(context.getString(R.string.captcha_required)) .setShowBadge(true) .setVibrationEnabled(false) @@ -41,8 +42,8 @@ class CaptchaNotifier( .setData(exception.url.toUri()) val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setContentTitle(channel.name) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setDefaults(NotificationCompat.DEFAULT_SOUND) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setDefaults(0) .setSmallIcon(android.R.drawable.stat_notify_error) .setGroup(GROUP_CAPTCHA) .setAutoCancel(true) @@ -84,20 +85,19 @@ class CaptchaNotifier( override fun onError(request: ImageRequest, result: ErrorResult) { super.onError(request, result) val e = result.throwable - if (e is CloudFlareProtectedException && request.parameters.value(PARAM_IGNORE_CAPTCHA) != true) { + if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) { notify(e) } } companion object { - fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter( - key = PARAM_IGNORE_CAPTCHA, - value = true, - memoryCacheKey = null, - ) + fun ImageRequest.Builder.ignoreCaptchaErrors() = apply { + extras[ignoreCaptchaKey] = true + } + + val ignoreCaptchaKey = Extras.Key(false) - private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha" private const val CHANNEL_ID = "captcha" private const val TAG = CHANNEL_ID private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt index b75711ae4..09738c113 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt @@ -32,6 +32,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import javax.inject.Inject import com.google.android.material.R as materialR @@ -175,8 +176,7 @@ class CloudFlareActivity : BaseActivity(), CloudFlareCal private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) { cookieJar.removeCookies(url) { cookie -> - val name = cookie.name - name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken" + CloudFlareHelper.isCloudFlareCookie(cookie.name) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt index 7bdd0aada..e5bd0c5cb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt @@ -2,11 +2,10 @@ package org.koitharu.kotatsu.browser.cloudflare import android.graphics.Bitmap import android.webkit.WebView -import okhttp3.HttpUrl.Companion.toHttpUrl import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.parsers.network.CloudFlareHelper -private const val CF_CLEARANCE = "cf_clearance" private const val LOOP_COUNTER = 3 class CloudFlareClient( @@ -50,8 +49,5 @@ class CloudFlareClient( } } - private fun getClearance(): String? { - return cookieJar.loadForRequest(targetUrl.toHttpUrl()) - .find { it.name == CF_CLEARANCE }?.value - } + private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt index f1abcfdb2..57ef8da27 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt @@ -1,18 +1,22 @@ package org.koitharu.kotatsu.core import android.app.Application -import android.content.ContentResolver import android.content.Context +import android.os.Build import android.provider.SearchRecentSuggestions import android.text.Html import androidx.collection.arraySetOf import androidx.room.InvalidationTracker import androidx.work.WorkManager -import coil.ComponentRegistry -import coil.ImageLoader -import coil.decode.SvgDecoder -import coil.disk.DiskCache -import coil.util.DebugLogger +import coil3.ImageLoader +import coil3.disk.DiskCache +import coil3.disk.directory +import coil3.gif.AnimatedImageDecoder +import coil3.gif.GifDecoder +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.allowRgb565 +import coil3.svg.SvgDecoder +import coil3.util.DebugLogger import dagger.Binds import dagger.Module import dagger.Provides @@ -28,6 +32,9 @@ import okhttp3.OkHttpClient import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.image.AvifImageDecoder +import org.koitharu.kotatsu.core.image.CbzFetcher +import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.os.AppShortcutManager @@ -44,7 +51,6 @@ import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer import org.koitharu.kotatsu.local.data.CacheDir -import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor @@ -81,9 +87,7 @@ interface AppModule { @Singleton fun provideMangaDatabase( @ApplicationContext context: Context, - ): MangaDatabase { - return MangaDatabase(context) - } + ): MangaDatabase = MangaDatabase(context) @Provides @Singleton @@ -94,6 +98,7 @@ interface AppModule { imageProxyInterceptor: ImageProxyInterceptor, pageFetcherFactory: MangaPageFetcher.Factory, coverRestoreInterceptor: CoverRestoreInterceptor, + networkStateProvider: Provider, ): ImageLoader { val diskCacheFactory = { val rootDir = context.externalCacheDir ?: context.cacheDir @@ -105,36 +110,39 @@ interface AppModule { okHttpClientProvider.get().newBuilder().cache(null).build() } return ImageLoader.Builder(context) - .okHttpClient { okHttpClientLazy.value } - .interceptorDispatcher(Dispatchers.Default) - .fetcherDispatcher(Dispatchers.Default) - .decoderDispatcher(Dispatchers.IO) - .transformationDispatcher(Dispatchers.Default) + .interceptorCoroutineContext(Dispatchers.Default) .diskCache(diskCacheFactory) - .respectCacheHeaders(false) - .networkObserverEnabled(false) .logger(if (BuildConfig.DEBUG) DebugLogger() else null) .allowRgb565(context.isLowRamDevice()) .eventListener(CaptchaNotifier(context)) - .components( - ComponentRegistry.Builder() - .add(SvgDecoder.Factory()) - .add(CbzFetcher.Factory()) - .add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory)) - .add(MangaPageKeyer()) - .add(pageFetcherFactory) - .add(imageProxyInterceptor) - .add(coverRestoreInterceptor) - .build(), - ).build() + .components { + add( + OkHttpNetworkFetcherFactory( + callFactory = okHttpClientLazy::value, + connectivityChecker = { networkStateProvider.get() }, + ), + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(AnimatedImageDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(SvgDecoder.Factory()) + add(CbzFetcher.Factory()) + add(AvifImageDecoder.Factory()) + add(FaviconFetcher.Factory(mangaRepositoryFactory)) + add(MangaPageKeyer()) + add(pageFetcherFactory) + add(imageProxyInterceptor) + add(coverRestoreInterceptor) + add(MangaSourceHeaderInterceptor()) + }.build() } @Provides fun provideSearchSuggestions( @ApplicationContext context: Context, - ): SearchRecentSuggestions { - return MangaSuggestionsProvider.createSuggestions(context) - } + ): SearchRecentSuggestions = MangaSuggestionsProvider.createSuggestions(context) @Provides @ElementsIntoSet diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt index 7d190422e..628093428 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt @@ -5,9 +5,11 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.net.Uri +import android.os.BadParcelableException import androidx.core.app.PendingIntentCompat import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.report class ErrorReporterReceiver : BroadcastReceiver() { @@ -22,12 +24,15 @@ class ErrorReporterReceiver : BroadcastReceiver() { private const val EXTRA_ERROR = "err" private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR" - fun getPendingIntent(context: Context, e: Throwable): PendingIntent { + fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try { val intent = Intent(context, ErrorReporterReceiver::class.java) intent.setAction(ACTION_REPORT) intent.setData(Uri.parse("err://${e.hashCode()}")) intent.putExtra(EXTRA_ERROR, e) - return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)) + PendingIntentCompat.getBroadcast(context, 0, intent, 0, false) + } catch (e: BadParcelableException) { + e.printStackTraceDebug() + null } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupFile.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupFile.kt new file mode 100644 index 000000000..1fb044ad1 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupFile.kt @@ -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 { + + override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt index 2071ae6d9..a8c45f0ca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt @@ -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()) { 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()) { 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()) { 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()) { 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()) { 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()) { result += runCatchingCancellable { settings.upsertAll(JsonDeserializer(item).toMap()) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt index 2fb7cd110..a1499afc7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt @@ -1,14 +1,11 @@ package org.koitharu.kotatsu.core.backup -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import okhttp3.internal.closeQuietly import okio.Closeable import org.json.JSONArray import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import java.io.File import java.util.EnumSet import java.util.zip.ZipException @@ -36,13 +33,9 @@ class BackupZipInput private constructor(val file: File) : Closeable { zipFile.close() } - fun cleanupAsync() { - processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) { - runCatching { - closeQuietly() - file.delete() - } - } + fun closeAndDelete() { + closeQuietly() + file.delete() } companion object { @@ -55,7 +48,7 @@ class BackupZipInput private constructor(val file: File) : Closeable { throw BadBackupFormatException(null) } res - } catch (exception: Exception) { + } catch (exception: Throwable) { res?.closeQuietly() throw if (exception is ZipException) { BadBackupFormatException(exception) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt index dbfe477c3..716e77fdf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt @@ -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" -suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { - val dir = context.run { - getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) - } - dir.mkdirs() - val filename = buildString { - append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) - append('_') - append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))) - append(".bk.zip") + companion object { + + 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))) + } } - BackupZipOutput(File(dir, filename)) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt new file mode 100644 index 000000000..9cf5b4cae --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt @@ -0,0 +1,75 @@ +package org.koitharu.kotatsu.core.backup + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.IOException +import okio.buffer +import okio.sink +import okio.source +import org.jetbrains.annotations.Blocking +import org.koitharu.kotatsu.core.prefs.AppSettings +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 = runInterruptible(Dispatchers.IO) { + getRoot().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 put(file: File): Uri = runInterruptible(Dispatchers.IO) { + val out = checkNotNull(getRoot().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 + } + + suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) { + val df = checkNotNull(DocumentFile.fromSingleUri(context, victim.uri)) { + "${victim.uri} cannot be resolved to the DocumentFile" + } + if (!df.delete()) { + throw IOException("Cannot delete ${df.uri}") + } + } + + suspend fun getLastBackupDate() = list().maxByOrNull { it.dateTime }?.dateTime + + suspend fun trim(maxCount: Int) { + list().drop(maxCount).forEach { + delete(it) + } + } + + @Blocking + private fun getRoot(): 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" } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareBlockedException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareBlockedException.kt index 244dba9f1..4860e4880 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareBlockedException.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareBlockedException.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.core.exceptions -import okhttp3.Headers import okio.IOException import org.koitharu.kotatsu.parsers.model.MangaSource diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/NoDataReceivedException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/NoDataReceivedException.kt index f8de32a1e..3594ce18b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/NoDataReceivedException.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/NoDataReceivedException.kt @@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.exceptions import okio.IOException class NoDataReceivedException( - private val url: String, + url: String, ) : IOException("No data has been received from $url") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt index 1edf3b662..8d2e34b9f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt @@ -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) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt index 44bd5d976..d843dd0d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt @@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultCaller import androidx.annotation.StringRes import androidx.collection.MutableScatterMap import androidx.fragment.app.FragmentManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -19,7 +18,8 @@ import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog -import org.koitharu.kotatsu.core.util.ext.findActivity +import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog +import org.koitharu.kotatsu.core.util.ext.restartApplication import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga @@ -124,15 +124,16 @@ class ExceptionResolver @AssistedInject constructor( Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() return } - MaterialAlertDialogBuilder(ctx) - .setTitle(R.string.ignore_ssl_errors) - .setMessage(R.string.ignore_ssl_errors_summary) - .setPositiveButton(R.string.apply) { _, _ -> + buildAlertDialog(ctx) { + setTitle(R.string.ignore_ssl_errors) + setMessage(R.string.ignore_ssl_errors_summary) + setPositiveButton(R.string.apply) { _, _ -> settings.isSSLBypassEnabled = true - Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show() - ctx.findActivity()?.finishAffinity() - }.setNegativeButton(android.R.string.cancel, null) - .show() + Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show() + ctx.restartApplication() + } + setNegativeButton(android.R.string.cancel, null) + }.show() } private inline fun Host.withContext(block: Context.() -> Unit) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt index e39897cfc..d5b55b750 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt @@ -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) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/fs/FileSequence.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/fs/FileSequence.kt index ab8713642..e58b96067 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/fs/FileSequence.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/fs/FileSequence.kt @@ -1,20 +1,31 @@ package org.koitharu.kotatsu.core.fs import android.os.Build -import org.koitharu.kotatsu.core.util.iterator.CloseableIterator +import androidx.annotation.RequiresApi +import org.koitharu.kotatsu.core.util.CloseableSequence import org.koitharu.kotatsu.core.util.iterator.MappingIterator import java.io.File import java.nio.file.Files import java.nio.file.Path -class FileSequence(private val dir: File) : Sequence { +sealed interface FileSequence : CloseableSequence { - override fun iterator(): Iterator { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val stream = Files.newDirectoryStream(dir.toPath()) - CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream) - } else { - dir.listFiles().orEmpty().iterator() - } + @RequiresApi(Build.VERSION_CODES.O) + class StreamImpl(dir: File) : FileSequence { + + private val stream = Files.newDirectoryStream(dir.toPath()) + + override fun iterator(): Iterator = MappingIterator(stream.iterator(), Path::toFile) + + override fun close() = stream.close() + } + + class ListImpl(dir: File) : FileSequence { + + private val list = dir.listFiles().orEmpty() + + override fun iterator(): Iterator = list.iterator() + + override fun close() = Unit } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt index a1d2a5544..4fb9d0c84 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt @@ -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(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 { 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? { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt index 56c931b24..9426401ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt @@ -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( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/AvifImageDecoder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/AvifImageDecoder.kt new file mode 100644 index 000000000..140fea957 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/AvifImageDecoder.kt @@ -0,0 +1,66 @@ +package org.koitharu.kotatsu.core.image + +import android.graphics.Bitmap +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DecodeResult +import coil3.decode.Decoder +import coil3.decode.ImageSource +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException +import kotlinx.coroutines.runInterruptible +import org.aomedia.avif.android.AvifDecoder +import org.aomedia.avif.android.AvifDecoder.Info +import org.koitharu.kotatsu.core.util.ext.toByteBuffer + +class AvifImageDecoder( + private val source: ImageSource, + private val options: Options, +) : Decoder { + + override suspend fun decode(): DecodeResult = runInterruptible { + val bytes = source.source().use { + it.inputStream().toByteBuffer() + } + val info = Info() + if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) { + throw ImageDecodeException( + null, + "avif", + "Requested to decode byte buffer which cannot be handled by AvifDecoder", + ) + } + val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565 + val bitmap = Bitmap.createBitmap(info.width, info.height, config) + if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) { + bitmap.recycle() + throw ImageDecodeException(null, "avif") + } + DecodeResult( + image = bitmap.asImage(), + isSampled = false, + ) + } + + class Factory : Decoder.Factory { + + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader + ): Decoder? = if (isApplicable(result)) { + AvifImageDecoder(result.source, options) + } else { + null + } + + override fun equals(other: Any?) = other is Factory + + override fun hashCode() = javaClass.hashCode() + + private fun isApplicable(result: SourceFetchResult): Boolean { + return result.mimeType == "image/avif" + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt new file mode 100644 index 000000000..80e9a4bdb --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt @@ -0,0 +1,77 @@ +package org.koitharu.kotatsu.core.image + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.os.Build +import android.webkit.MimeTypeMap +import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import org.aomedia.avif.android.AvifDecoder +import org.aomedia.avif.android.AvifDecoder.Info +import org.jetbrains.annotations.Blocking +import org.koitharu.kotatsu.core.util.ext.toByteBuffer +import java.io.File +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.file.Files + +object BitmapDecoderCompat { + + private const val FORMAT_AVIF = "avif" + + @Blocking + fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) { + FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) } + else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(file)) + } else { + checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format) + } + } + + @Blocking + fun decode(stream: InputStream, type: MediaType?): Bitmap { + val format = type?.subtype + if (format == FORMAT_AVIF) { + return decodeAvif(stream.toByteBuffer()) + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return checkBitmapNotNull(BitmapFactory.decodeStream(stream), format) + } + val byteBuffer = stream.toByteBuffer() + return if (AvifDecoder.isAvifImage(byteBuffer)) { + decodeAvif(byteBuffer) + } else { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer)) + } + } + + private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Files.probeContentType(file.toPath())?.toMediaTypeOrNull() + } else { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull() + } + + private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap = + bitmap ?: throw ImageDecodeException(null, format) + + private fun decodeAvif(bytes: ByteBuffer): Bitmap { + val info = Info() + if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) { + throw ImageDecodeException( + null, + FORMAT_AVIF, + "Requested to decode byte buffer which cannot be handled by AvifDecoder", + ) + } + val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565 + val bitmap = Bitmap.createBitmap(info.width, info.height, config) + if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) { + bitmap.recycle() + throw ImageDecodeException(null, FORMAT_AVIF) + } + return bitmap + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt new file mode 100644 index 000000000..9f52ff8c0 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.core.image + +import android.net.Uri +import android.webkit.MimeTypeMap +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import coil3.toAndroidUri +import kotlinx.coroutines.runInterruptible +import okio.Path.Companion.toPath +import okio.openZip +import org.koitharu.kotatsu.core.util.ext.isZipUri +import coil3.Uri as CoilUri + +class CbzFetcher( + private val uri: Uri, + private val options: Options, +) : Fetcher { + + override suspend fun fetch() = runInterruptible { + val filePath = uri.schemeSpecificPart.toPath() + val entryName = requireNotNull(uri.fragment) + SourceFetchResult( + source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)), + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")), + dataSource = DataSource.DISK, + ) + } + + class Factory : Fetcher.Factory { + + override fun create( + data: CoilUri, + options: Options, + imageLoader: ImageLoader + ): Fetcher? { + val androidUri = data.toAndroidUri() + return if (androidUri.isZipUri()) { + CbzFetcher(androidUri, options) + } else { + null + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/MangaSourceHeaderInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/MangaSourceHeaderInterceptor.kt new file mode 100644 index 000000000..be4c068a4 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/MangaSourceHeaderInterceptor.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.core.image + +import coil3.intercept.Interceptor +import coil3.network.httpHeaders +import coil3.request.ImageResult +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.util.ext.mangaSourceKey +import org.koitharu.kotatsu.parsers.model.MangaParserSource + +class MangaSourceHeaderInterceptor : Interceptor { + + override suspend fun intercept(chain: Interceptor.Chain): ImageResult { + val mangaSource = chain.request.extras[mangaSourceKey] as? MangaParserSource ?: return chain.proceed() + val request = chain.request + val newHeaders = request.httpHeaders.newBuilder() + .set(CommonHeaders.MANGA_SOURCE, mangaSource.name) + .build() + val newRequest = request.newBuilder() + .httpHeaders(newHeaders) + .build() + return chain.withRequest(newRequest).proceed() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/RegionBitmapDecoder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/RegionBitmapDecoder.kt similarity index 70% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/RegionBitmapDecoder.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/core/image/RegionBitmapDecoder.kt index 47d5461cb..c397c3b8d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/RegionBitmapDecoder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/RegionBitmapDecoder.kt @@ -1,39 +1,39 @@ -package org.koitharu.kotatsu.core.ui.image +package org.koitharu.kotatsu.core.image import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.BitmapRegionDecoder import android.graphics.Rect import android.os.Build -import androidx.core.graphics.drawable.toDrawable -import coil.ImageLoader -import coil.decode.DecodeResult -import coil.decode.DecodeUtils -import coil.decode.Decoder -import coil.decode.ImageSource -import coil.fetch.SourceResult -import coil.request.Options -import coil.size.Dimension -import coil.size.Scale -import coil.size.Size -import coil.size.isOriginal -import coil.size.pxOrElse +import coil3.Extras +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DecodeResult +import coil3.decode.DecodeUtils +import coil3.decode.Decoder +import coil3.decode.ImageSource +import coil3.fetch.SourceFetchResult +import coil3.getExtra +import coil3.request.Options +import coil3.request.allowRgb565 +import coil3.request.bitmapConfig +import coil3.request.colorSpace +import coil3.request.premultipliedAlpha +import coil3.size.Dimension +import coil3.size.Precision +import coil3.size.Scale +import coil3.size.Size +import coil3.size.isOriginal +import coil3.size.pxOrElse import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlin.math.roundToInt class RegionBitmapDecoder( private val source: ImageSource, private val options: Options, - private val parallelismLock: Semaphore, ) : Decoder { - override suspend fun decode() = parallelismLock.withPermit { - runInterruptible { BitmapFactory.Options().decode() } - } - - private fun BitmapFactory.Options.decode(): DecodeResult { + override suspend fun decode(): DecodeResult = runInterruptible { val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { BitmapRegionDecoder.newInstance(source.source().inputStream()) } else { @@ -41,13 +41,14 @@ class RegionBitmapDecoder( BitmapRegionDecoder.newInstance(source.source().inputStream(), false) } checkNotNull(regionDecoder) + val bitmapOptions = BitmapFactory.Options() try { - val rect = configureScale(regionDecoder.width, regionDecoder.height) - configureConfig() - val bitmap = regionDecoder.decodeRegion(rect, this) + val rect = bitmapOptions.configureScale(regionDecoder.width, regionDecoder.height) + bitmapOptions.configureConfig() + val bitmap = regionDecoder.decodeRegion(rect, bitmapOptions) bitmap.density = options.context.resources.displayMetrics.densityDpi - return DecodeResult( - drawable = bitmap.toDrawable(options.context.resources), + DecodeResult( + image = bitmap.asImage(), isSampled = true, ) } finally { @@ -55,29 +56,6 @@ class RegionBitmapDecoder( } } - private fun BitmapFactory.Options.configureConfig() { - var config = options.config - - inMutable = false - - if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) { - inPreferredColorSpace = options.colorSpace - } - inPremultiplied = options.premultipliedAlpha - - // Decode the image as RGB_565 as an optimization if allowed. - if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") { - config = Bitmap.Config.RGB_565 - } - - // High color depth images must be decoded as either RGBA_F16 or HARDWARE. - if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) { - config = Bitmap.Config.RGBA_F16 - } - - inPreferredConfig = config - } - /** Compute and set the scaling properties for [BitmapFactory.Options]. */ private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect { val dstWidth = options.size.widthPx(options.scale) { srcWidth } @@ -91,7 +69,7 @@ class RegionBitmapDecoder( } else { Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight) } - val scroll = options.parameters.value(PARAM_SCROLL) ?: SCROLL_UNDEFINED + val scroll = options.getExtra(regionScrollKey) if (scroll == SCROLL_UNDEFINED) { rect.offsetTo( (srcWidth - rect.width()) / 2, @@ -123,7 +101,7 @@ class RegionBitmapDecoder( ) // Only upscale the image if the options require an exact size. - if (options.allowInexactSize) { + if (options.precision == Precision.INEXACT) { scale = scale.coerceAtMost(1.0) } @@ -142,20 +120,37 @@ class RegionBitmapDecoder( return rect } - class Factory( - maxParallelism: Int = DEFAULT_MAX_PARALLELISM, - ) : Decoder.Factory { + private fun BitmapFactory.Options.configureConfig() { + var config = options.bitmapConfig - @Suppress("NEWER_VERSION_IN_SINCE_KOTLIN") - @SinceKotlin("999.9") // Only public in Java. - constructor() : this() + inMutable = false - private val parallelismLock = Semaphore(maxParallelism) + if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) { + inPreferredColorSpace = options.colorSpace + } + inPremultiplied = options.premultipliedAlpha - override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder { - return RegionBitmapDecoder(result.source, options, parallelismLock) + // Decode the image as RGB_565 as an optimization if allowed. + if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") { + config = Bitmap.Config.RGB_565 } + // High color depth images must be decoded as either RGBA_F16 or HARDWARE. + if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) { + config = Bitmap.Config.RGBA_F16 + } + + inPreferredConfig = config + } + + object Factory : Decoder.Factory { + + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader + ): Decoder = RegionBitmapDecoder(result.source, options) + override fun equals(other: Any?) = other is Factory override fun hashCode() = javaClass.hashCode() @@ -163,9 +158,8 @@ class RegionBitmapDecoder( companion object { - const val PARAM_SCROLL = "scroll" const val SCROLL_UNDEFINED = -1 - private const val DEFAULT_MAX_PARALLELISM = 4 + val regionScrollKey = Extras.Key(SCROLL_UNDEFINED) private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int { return if (isOriginal) original() else width.toPx(scale) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/io/NullOutputStream.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/io/NullOutputStream.kt new file mode 100644 index 000000000..d02cdf97b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/io/NullOutputStream.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt index 227a64035..5fdd13a8b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt @@ -2,41 +2,43 @@ package org.koitharu.kotatsu.core.network import okhttp3.Interceptor import okhttp3.Response -import okhttp3.internal.closeQuietly -import org.jsoup.Jsoup +import okio.IOException import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.parsers.model.MangaSource -import java.net.HttpURLConnection.HTTP_FORBIDDEN -import java.net.HttpURLConnection.HTTP_UNAVAILABLE +import org.koitharu.kotatsu.parsers.network.CloudFlareHelper class CloudFlareInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val response = chain.proceed(chain.request()) - if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) { - val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use { - Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString()) - } ?: return response - val hasCaptcha = content.getElementById("challenge-error-title") != null - val isBlocked = content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null - if (hasCaptcha || isBlocked) { - val request = response.request - response.closeQuietly() - if (isBlocked) { - throw CloudFlareBlockedException( - url = request.url.toString(), - source = request.tag(MangaSource::class.java), - ) - } else { - throw CloudFlareProtectedException( - url = request.url.toString(), - source = request.tag(MangaSource::class.java), - headers = request.headers, - ) - } - } + val request = chain.request() + val response = chain.proceed(request) + return when (CloudFlareHelper.checkResponseForProtection(response)) { + CloudFlareHelper.PROTECTION_BLOCKED -> response.closeThrowing( + CloudFlareBlockedException( + url = request.url.toString(), + source = request.tag(MangaSource::class.java), + ), + ) + + CloudFlareHelper.PROTECTION_CAPTCHA -> response.closeThrowing( + CloudFlareProtectedException( + url = request.url.toString(), + source = request.tag(MangaSource::class.java), + headers = request.headers, + ), + ) + + else -> response + } + } + + private fun Response.closeThrowing(error: IOException): Nothing { + try { + close() + } catch (e: Exception) { + error.addSuppressed(e) } - return response + throw error } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt index 9a31233a0..f63117f81 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -16,6 +16,7 @@ object CommonHeaders { const val CACHE_CONTROL = "Cache-Control" const val PROXY_AUTHORIZATION = "Proxy-Authorization" const val RETRY_AFTER = "Retry-After" + const val MANGA_SOURCE = "X-Manga-Source" val CACHE_CONTROL_NO_STORE: CacheControl get() = CacheControl.Builder().noStore().build() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt index 8a8094a14..82d0148d5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt @@ -9,10 +9,12 @@ import okhttp3.Request import okhttp3.Response import okio.IOException import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.runCatchingCancellable @@ -29,15 +31,17 @@ class CommonHeadersInterceptor @Inject constructor( override fun intercept(chain: Chain): Response { val request = chain.request() val source = request.tag(MangaSource::class.java) - val repository = if (source != null) { + ?: request.headers[CommonHeaders.MANGA_SOURCE]?.let { MangaSource(it) } + val repository = if (source is MangaParserSource) { mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository } else { - if (BuildConfig.DEBUG) { + if (BuildConfig.DEBUG && source == null) { Log.w("Http", "Request without source tag: ${request.url}") } null } val headersBuilder = request.headers.newBuilder() + .removeAll(CommonHeaders.MANGA_SOURCE) repository?.getRequestHeaders()?.let { headersBuilder.mergeWith(it, replaceExisting = false) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/BaseImageProxyInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/BaseImageProxyInterceptor.kt index b88966367..ce7451008 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/BaseImageProxyInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/BaseImageProxyInterceptor.kt @@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.network.imageproxy import android.util.Log import androidx.collection.ArraySet -import coil.intercept.Interceptor -import coil.network.HttpException -import coil.request.ErrorResult -import coil.request.ImageRequest -import coil.request.ImageResult -import coil.request.SuccessResult +import coil3.intercept.Interceptor +import coil3.network.HttpException +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.ImageResult +import coil3.request.SuccessResult import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient @@ -35,14 +35,14 @@ abstract class BaseImageProxyInterceptor : ImageProxyInterceptor { else -> null } if (url == null || !url.isHttpOrHttps || url.host in blacklist) { - return chain.proceed(request) + return chain.proceed() } val newRequest = onInterceptImageRequest(request, url) - return when (val result = chain.proceed(newRequest)) { + return when (val result = chain.withRequest(newRequest).proceed()) { is SuccessResult -> result is ErrorResult -> { logDebug(result.throwable, newRequest.data) - chain.proceed(request).also { + chain.proceed().also { if (it is SuccessResult && result.throwable.isBlockedByServer()) { blacklist.add(url.host) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ImageProxyInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ImageProxyInterceptor.kt index 7b5e4b1bc..9d05aaf63 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ImageProxyInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ImageProxyInterceptor.kt @@ -1,6 +1,6 @@ package org.koitharu.kotatsu.core.network.imageproxy -import coil.intercept.Interceptor +import coil3.intercept.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/RealImageProxyInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/RealImageProxyInterceptor.kt index 2ade36803..c3b7cfe0b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/RealImageProxyInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/RealImageProxyInterceptor.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.core.network.imageproxy -import coil.intercept.Interceptor -import coil.request.ImageResult +import coil3.intercept.Interceptor +import coil3.request.ImageResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.plus import okhttp3.OkHttpClient @@ -26,7 +26,7 @@ class RealImageProxyInterceptor @Inject constructor( ) override suspend fun intercept(chain: Interceptor.Chain): ImageResult { - return delegate.value?.intercept(chain) ?: chain.proceed(chain.request) + return delegate.value?.intercept(chain) ?: chain.proceed() } override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/WsrvNlProxyInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/WsrvNlProxyInterceptor.kt index 2645bf78c..13f821b9b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/WsrvNlProxyInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/WsrvNlProxyInterceptor.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.core.network.imageproxy -import coil.request.ImageRequest -import coil.size.Dimension -import coil.size.isOriginal +import coil3.request.ImageRequest +import coil3.size.Dimension +import coil3.size.isOriginal import okhttp3.HttpUrl import okhttp3.Request diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ZeroMsProxyInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ZeroMsProxyInterceptor.kt index ba7670d67..2fe1f4f6d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ZeroMsProxyInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/imageproxy/ZeroMsProxyInterceptor.kt @@ -1,6 +1,6 @@ package org.koitharu.kotatsu.core.network.imageproxy -import coil.request.ImageRequest +import coil3.request.ImageRequest import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt index 414a8a24f..e406d8e55 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt @@ -10,10 +10,11 @@ import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.toBitmap import androidx.room.InvalidationTracker -import coil.ImageLoader -import coil.request.ImageRequest -import coil.size.Scale -import coil.size.Size +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.transformations +import coil3.size.Scale +import coil3.size.Size import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,9 +28,9 @@ import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -138,7 +139,7 @@ class AppShortcutManager @Inject constructor( ImageRequest.Builder(context) .data(manga.coverUrl) .size(iconSize) - .source(manga.source) + .mangaSourceExtra(manga.source) .scale(Scale.FILL) .transformations(ThumbnailTransformation()) .build(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt index 564db96aa..0d98d2368 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt @@ -6,6 +6,7 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build +import coil3.network.ConnectivityChecker import kotlinx.coroutines.flow.first import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.MediatorStateFlow @@ -13,13 +14,17 @@ import org.koitharu.kotatsu.core.util.MediatorStateFlow class NetworkState( private val connectivityManager: ConnectivityManager, private val settings: AppSettings, -) : MediatorStateFlow(connectivityManager.isOnline(settings)) { +) : MediatorStateFlow(connectivityManager.isOnline(settings)), ConnectivityChecker { private val callback = NetworkCallbackImpl() override val value: Boolean get() = connectivityManager.isOnline(settings) + override fun isOnline(): Boolean { + return connectivityManager.isOnline(settings) + } + @Synchronized override fun onActive() { invalidate() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/CachingMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/CachingMangaRepository.kt index 261f78900..6c55dfd3d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/CachingMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/CachingMangaRepository.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.parser import android.util.Log import androidx.collection.MutableLongSet -import coil.request.CachePolicy +import coil3.request.CachePolicy import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt index 54bdced1d..c831b1623 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt @@ -1,13 +1,13 @@ package org.koitharu.kotatsu.core.parser import android.net.Uri -import coil.request.CachePolicy +import coil3.request.CachePolicy import dagger.Reusable import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository +import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter @@ -15,21 +15,20 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.parsers.util.toRelativeUrl import javax.inject.Inject @Reusable class MangaLinkResolver @Inject constructor( private val repositoryFactory: MangaRepository.Factory, - private val sourcesRepository: MangaSourcesRepository, private val dataRepository: MangaDataRepository, + private val context: MangaLoaderContext, ) { suspend fun resolve(uri: Uri): Manga { return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") { resolveAppLink(uri) } else { - resolveExternalLink(uri) + resolveExternalLink(uri.toString()) } ?: throw NotFoundException("Cannot resolve link", uri.toString()) } @@ -45,18 +44,11 @@ class MangaLinkResolver @Inject constructor( ) } - private suspend fun resolveExternalLink(uri: Uri): Manga? { - dataRepository.findMangaByPublicUrl(uri.toString())?.let { + private suspend fun resolveExternalLink(uri: String): Manga? { + dataRepository.findMangaByPublicUrl(uri)?.let { return it } - val host = uri.host ?: return null - val repo = sourcesRepository.allMangaSources.asSequence() - .map { source -> - repositoryFactory.create(source) as ParserMangaRepository - }.find { repo -> - host in repo.domains - } ?: return null - return repo.findExact(uri.toString().toRelativeUrl(host), null) + return context.newLinkResolver(uri).getManga() } private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { @@ -85,12 +77,10 @@ class MangaLinkResolver @Inject constructor( }.getOrThrow() } - private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { - return if (this is ParserMangaRepository) { - getDetails(manga, CachePolicy.READ_ONLY) - } else { - getDetails(manga) - } + private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga = if (this is CachingMangaRepository) { + getDetails(manga, CachePolicy.READ_ONLY) + } else { + getDetails(manga) } private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt index b6fb9f9f3..824aea5d2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt @@ -7,6 +7,7 @@ import android.util.Base64 import android.webkit.WebView import androidx.annotation.MainThread import androidx.core.os.LocaleListCompat +import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -30,6 +31,7 @@ import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.util.map +import org.koitharu.kotatsu.parsers.util.mimeType import java.lang.ref.WeakReference import java.util.Locale import javax.inject.Inject @@ -86,7 +88,7 @@ class MangaLoaderContextImpl @Inject constructor( result.compressTo(it.outputStream()) }.asResponseBody("image/jpeg".toMediaType()) } - } ?: error("Cannot decode bitmap") + } ?: throw ImageDecodeException(response.request.url.toString(), response.mimeType) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt index 82a614891..12f4cfcee 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt @@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter @@ -17,13 +16,10 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.util.Locale class ParserMangaRepository( private val parser: MangaParser, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt index 2f4e9db24..dece02536 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt @@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.util.SuspendLazy import java.util.EnumSet class ExternalMangaRepository( - private val contentResolver: ContentResolver, + contentResolver: ContentResolver, override val source: ExternalMangaSource, cache: MemoryContentCache, ) : CachingMangaRepository(cache) { @@ -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() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt index 103b40d66..a0ca968e2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt @@ -8,6 +8,7 @@ import androidx.core.net.toUri import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty +import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.Demographic @@ -141,7 +142,7 @@ class ExternalPluginContentSource( @Blocking @WorkerThread fun getPageUrl(url: String): String { - val uri = "content://${source.authority}/pages/0".toUri().buildUpon() + val uri = "content://${source.authority}/manga/pages/0".toUri().buildUpon() .appendQueryParameter("url", url) .build() return contentResolver.query(uri, null, null, null, null) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt index 1d28509c7..a47b03050 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.core.parser.favicon -import android.content.Context import android.graphics.Color import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.ColorDrawable @@ -8,232 +7,130 @@ import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.net.Uri import android.os.Build -import android.webkit.MimeTypeMap -import coil.ImageLoader -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.disk.DiskCache -import coil.fetch.DrawableResult -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.network.HttpException -import coil.request.Options -import coil.size.Size -import coil.size.pxOrElse -import kotlinx.coroutines.Dispatchers +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.request.Options +import coil3.size.pxOrElse +import coil3.toAndroidUri import kotlinx.coroutines.ensureActive import kotlinx.coroutines.runInterruptible -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody -import okhttp3.internal.closeQuietly -import okio.Closeable -import okio.buffer +import okio.IOException +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.parser.EmptyMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository -import org.koitharu.kotatsu.core.util.ext.writeAllCancellable -import org.koitharu.kotatsu.local.data.CacheDir -import org.koitharu.kotatsu.local.data.util.withExtraCloseable -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.requireBody -import java.net.HttpURLConnection +import org.koitharu.kotatsu.core.util.ext.fetch +import org.koitharu.kotatsu.local.data.LocalMangaRepository import kotlin.coroutines.coroutineContext - -private const val FALLBACK_SIZE = 9999 // largest icon +import coil3.Uri as CoilUri class FaviconFetcher( - private val okHttpClient: OkHttpClient, - private val diskCache: Lazy, - private val mangaSource: MangaSource, + private val uri: Uri, private val options: Options, + private val imageLoader: ImageLoader, private val mangaRepositoryFactory: MangaRepository.Factory, ) : Fetcher { - private val diskCacheKey - get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}" - - private val fileSystem - get() = checkNotNull(diskCache.value).fileSystem + override suspend fun fetch(): FetchResult? { + val mangaSource = MangaSource(uri.schemeSpecificPart) - override suspend fun fetch(): FetchResult { - getCached(options)?.let { return it } return when (val repo = mangaRepositoryFactory.create(mangaSource)) { is ParserMangaRepository -> fetchParserFavicon(repo) is ExternalMangaRepository -> fetchPluginIcon(repo) - is EmptyMangaRepository -> DrawableResult( - drawable = ColorDrawable(Color.WHITE), + is EmptyMangaRepository -> ImageFetchResult( + image = ColorDrawable(Color.WHITE).asImage(), isSampled = false, dataSource = DataSource.MEMORY, ) - else -> throw IllegalArgumentException("") + is LocalMangaRepository -> imageLoader.fetch(R.drawable.ic_storage, options) + + else -> throw IllegalArgumentException("Unsupported repo ${repo.javaClass.simpleName}") } } - private suspend fun fetchParserFavicon(repo: ParserMangaRepository): FetchResult { + private suspend fun fetchParserFavicon(repository: ParserMangaRepository): FetchResult { val sizePx = maxOf( options.size.width.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE }, ) - var favicons = repo.getFavicons() + var favicons = repository.getFavicons() var lastError: Exception? = null while (favicons.isNotEmpty()) { coroutineContext.ensureActive() val icon = favicons.find(sizePx) ?: throwNSEE(lastError) - val response = try { - loadIcon(icon.url, mangaSource) + try { + val result = imageLoader.fetch(icon.url, options) + if (result != null) { + return result + } else { + favicons -= icon + } } catch (e: CloudFlareProtectedException) { throw e - } catch (e: HttpException) { + } catch (e: IOException) { lastError = e favicons -= icon - continue } - val responseBody = response.requireBody() - val source = writeToDiskCache(responseBody)?.toImageSource()?.also { - response.closeQuietly() - } ?: responseBody.toImageSource(response) - return SourceResult( - source = source, - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type), - dataSource = response.toDataSource(), - ) } throwNSEE(lastError) } - private suspend fun loadIcon(url: String, source: MangaSource): Response { - val request = Request.Builder() - .url(url) - .get() - .tag(MangaSource::class.java, source) - request.tag(MangaSource::class.java, source) - @Suppress("UNCHECKED_CAST") - options.tags.asMap().forEach { request.tag(it.key as Class, it.value) } - val response = okHttpClient.newCall(request.build()).await() - if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) { - response.closeQuietly() - throw HttpException(response) - } - return response - } - private suspend fun fetchPluginIcon(repository: ExternalMangaRepository): FetchResult { val source = repository.source val pm = options.context.packageManager - val icon = runInterruptible(Dispatchers.IO) { + val icon = runInterruptible { val provider = pm.resolveContentProvider(source.authority, 0) provider?.loadIcon(pm) ?: pm.getApplicationIcon(source.packageName) } - return DrawableResult( - drawable = icon.nonAdaptive(), + return ImageFetchResult( + image = icon.nonAdaptive().asImage(), isSampled = false, dataSource = DataSource.DISK, ) } - private fun getCached(options: Options): SourceResult? { - if (!options.diskCachePolicy.readEnabled) { - return null - } - val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null - return SourceResult( - source = snapshot.toImageSource(), - mimeType = null, - dataSource = DataSource.DISK, - ) - } - - private suspend fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? { - if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) { - return null - } - val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null - try { - fileSystem.write(editor.data) { - writeAllCancellable(body.source()) - } - return editor.commitAndOpenSnapshot() - } catch (e: Throwable) { - try { - editor.abort() - } catch (abortingError: Throwable) { - e.addSuppressed(abortingError) - } - body.closeQuietly() - throw e - } finally { - body.closeQuietly() - } - } - - private fun DiskCache.Snapshot.toImageSource(): ImageSource { - return ImageSource(data, fileSystem, diskCacheKey, this) - } - - private fun ResponseBody.toImageSource(response: Closeable): ImageSource { - return ImageSource( - source().withExtraCloseable(response).buffer(), - options.context, - FaviconMetadata(mangaSource), - ) - } - - private fun Response.toDataSource(): DataSource { - return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK - } - - private fun Size.toCacheKey() = buildString { - append(width.toString()) - append('x') - append(height.toString()) - } - - private fun throwNSEE(lastError: Exception?): Nothing { - if (lastError != null) { - throw lastError + class Factory( + private val mangaRepositoryFactory: MangaRepository.Factory, + ) : Fetcher.Factory { + + override fun create( + data: CoilUri, + options: Options, + imageLoader: ImageLoader + ): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) { + FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory) } else { - throw NoSuchElementException("No favicons found") + null } } - private fun Drawable.nonAdaptive() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) { - LayerDrawable(arrayOf(background, foreground)) - } else { - this - } + private companion object { - class Factory( - context: Context, - okHttpClientLazy: Lazy, - private val mangaRepositoryFactory: MangaRepository.Factory, - ) : Fetcher.Factory { - - private val okHttpClient by okHttpClientLazy - private val diskCache = lazy { - val rootDir = context.externalCacheDir ?: context.cacheDir - DiskCache.Builder() - .directory(rootDir.resolve(CacheDir.FAVICONS.dir)) - .build() - } + const val FALLBACK_SIZE = 9999 // largest icon - override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { - return if (data.scheme == URI_SCHEME_FAVICON) { - val mangaSource = MangaSource(data.schemeSpecificPart) - FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory) + private fun throwNSEE(lastError: Exception?): Nothing { + if (lastError != null) { + throw lastError } else { - null + throw NoSuchElementException("No favicons found") } } - } - class FaviconMetadata(val source: MangaSource) : ImageSource.Metadata() + private fun Drawable.nonAdaptive() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this is AdaptiveIconDrawable) { + LayerDrawable(arrayOf(background, foreground)) + } else { + this + } + + } } + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 84a7c2db3..5f0d06617 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.prefs import android.content.Context import android.content.SharedPreferences +import android.content.pm.ActivityInfo import android.net.ConnectivityManager import android.net.Uri import android.os.Build @@ -11,6 +12,7 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.collection.ArraySet import androidx.core.content.edit import androidx.core.os.LocaleListCompat +import androidx.core.util.TimeUtils import androidx.documentfile.provider.DocumentFile import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext @@ -33,6 +35,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 @@ -127,6 +130,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false) set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) } + val readerScreenOrientation: Int + get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull() + ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + val isReaderVolumeButtonsEnabled: Boolean get() = prefs.getBoolean(KEY_READER_VOLUME_BUTTONS, false) @@ -142,10 +149,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isReaderOptimizationEnabled: Boolean get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false) - var isTrafficWarningEnabled: Boolean - get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) - set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } - val isOfflineCheckDisabled: Boolean get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false) @@ -336,8 +339,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { } } - val isDownloadsWiFiOnly: Boolean - get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) + var allowDownloadOnMeteredNetwork: TriStateOption + get() = prefs.getEnumValue(KEY_DOWNLOADS_METERED_NETWORK, TriStateOption.ASK) + set(value) = prefs.edit { putEnumValue(KEY_DOWNLOADS_METERED_NETWORK, value) } val preferredDownloadFormat: DownloadFormat get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC) @@ -477,9 +481,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) val periodicalBackupFrequency: Long - get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L + get() = TimeUnit.DAYS.toMillis(prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L) + + val periodicalBackupMaxCount: Int + get() = prefs.getInt(KEY_BACKUP_PERIODICAL_COUNT, 10) - var periodicalBackupOutput: Uri? + var periodicalBackupDirectory: Uri? get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull() set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } @@ -581,7 +588,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_THEME = "theme" const val KEY_COLOR_THEME = "color_theme" const val KEY_THEME_AMOLED = "amoled_theme" - const val KEY_TRAFFIC_WARNING = "traffic_warning" const val KEY_OFFLINE_DISABLED = "no_offline" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" @@ -600,6 +606,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_READER_CONTROL_LTR = "reader_taps_ltr" const val KEY_READER_FULLSCREEN = "reader_fullscreen" const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons" + const val KEY_READER_ORIENTATION = "reader_orientation" const val KEY_TRACKER_ENABLED = "tracker_enabled" const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi" const val KEY_TRACKER_FREQUENCY = "tracker_freq" @@ -627,6 +634,7 @@ 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_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" @@ -647,7 +655,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_ANILIST = "anilist" const val KEY_MAL = "mal" const val KEY_KITSU = "kitsu" - const val KEY_DOWNLOADS_WIFI = "downloads_wifi" + const val KEY_DOWNLOADS_METERED_NETWORK = "downloads_metered_network" const val KEY_DOWNLOADS_FORMAT = "downloads_format" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_DOH = "doh" @@ -718,7 +726,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_LINK_TELEGRAM = "about_telegram" const val KEY_LINK_GITHUB = "about_github" const val KEY_LINK_MANUAL = "about_help" - const val PROXY_TEST = "proxy_test" + const val KEY_PROXY_TEST = "proxy_test" + const val KEY_OPEN_BROWSER = "open_browser" // old keys are for migration only private const val KEY_IMAGES_PROXY_OLD = "images_proxy" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/TriStateOption.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/TriStateOption.kt new file mode 100644 index 000000000..143ac3b4b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/TriStateOption.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.core.prefs + +import androidx.annotation.Keep + +@Keep +enum class TriStateOption { + + ENABLED, ASK, DISABLED; +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt index 357e792e4..db75d16e7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt @@ -9,7 +9,10 @@ import androidx.annotation.CallSuper import androidx.annotation.StringRes import androidx.core.graphics.Insets import androidx.core.view.updatePadding +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceScreen +import androidx.preference.get import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -20,9 +23,11 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.getThemeDrawable import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.settings.SettingsActivity import javax.inject.Inject +import com.google.android.material.R as materialR @AndroidEntryPoint abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : @@ -67,6 +72,10 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : override fun onResume() { super.onResume() setTitle(if (titleId != 0) getString(titleId) else null) + arguments?.getString(SettingsActivity.ARG_PREF_KEY)?.let { + focusPreference(it) + arguments?.remove(SettingsActivity.ARG_PREF_KEY) + } } @CallSuper @@ -87,4 +96,31 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() false } + + private fun focusPreference(key: String) { + val pref = findPreference(key) + if (pref == null) { + scrollToPreference(key) + return + } + scrollToPreference(pref) + val prefIndex = preferenceScreen.indexOf(key) + val view = if (prefIndex >= 0) { + listView.findViewHolderForAdapterPosition(prefIndex)?.itemView ?: return + } else { + return + } + view.context.getThemeDrawable(materialR.attr.colorTertiaryContainer)?.let { + view.background = it + } + } + + private fun PreferenceScreen.indexOf(key: String): Int { + for (i in 0 until preferenceCount) { + if (get(i).key == key) { + return i + } + } + return -1 + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt index 5441134cf..1c555b82e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt @@ -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,104 @@ 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 fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast( - this, - 0, - createCancelIntent(this, startId), - PendingIntent.FLAG_UPDATE_CURRENT, - false, - ) - - private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable -> - throwable.printStackTraceDebug() - onError(startId, throwable) + protected abstract fun IntentJobContext.onError(error: Throwable) + + interface IntentJobContext { + + 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 { unregisterReceiver(it) } + 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 = CancelReceiver(job).also { receiver -> + ContextCompat.registerReceiver( + applicationContext, + receiver, + createIntentFilter(this@CoroutineIntentService, startId), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + } + } + } + } } private class CancelReceiver( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt index c34cb8382..97543d692 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.withContext import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.model.ListModel -import java.util.Collections +import org.koitharu.kotatsu.parsers.util.move import java.util.LinkedList open class ReorderableListAdapter : ListDelegationAdapter>(), FlowCollector?> { @@ -28,13 +28,17 @@ open class ReorderableListAdapter : ListDelegationAdapter listListeners.forEach { it.onCurrentListChanged(oldList, newList) } } - @Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR) - override fun setItems(items: List?) { - super.setItems(items) - } + @Deprecated( + message = "Use emit() to dispatch list updates", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith("emit(items)"), + ) + override fun setItems(items: List?) = super.setItems(items) fun reorderItems(oldPos: Int, newPos: Int) { - Collections.swap(items ?: return, oldPos, newPos) + val reordered = items?.toMutableList() ?: return + reordered.move(oldPos, newPos) + super.setItems(reordered) notifyItemMoved(oldPos, newPos) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/TwoButtonsAlertDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/BigButtonsAlertDialog.kt similarity index 63% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/TwoButtonsAlertDialog.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/BigButtonsAlertDialog.kt index 4d15077e1..600fda6de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/TwoButtonsAlertDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/BigButtonsAlertDialog.kt @@ -6,12 +6,13 @@ import android.view.LayoutInflater import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone import androidx.core.view.isVisible import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding -class TwoButtonsAlertDialog private constructor( +class BigButtonsAlertDialog private constructor( private val delegate: AlertDialog ) : DialogInterface by delegate { @@ -51,14 +52,44 @@ class TwoButtonsAlertDialog private constructor( @StringRes textId: Int, listener: DialogInterface.OnClickListener? = null ): Builder { - initButton(binding.button2, DialogInterface.BUTTON_NEGATIVE, textId, listener) + initButton(binding.button3, DialogInterface.BUTTON_NEGATIVE, textId, listener) return this } - fun create(): TwoButtonsAlertDialog { + fun setNeutralButton( + @StringRes textId: Int, + listener: DialogInterface.OnClickListener? = null + ): Builder { + initButton(binding.button2, DialogInterface.BUTTON_NEUTRAL, textId, listener) + return this + } + + fun create(): BigButtonsAlertDialog { + with(binding) { + button1.adjustCorners(isFirst = true, isLast = button2.isGone && button3.isGone) + button2.adjustCorners(isFirst = button1.isGone, isLast = button3.isGone) + button3.adjustCorners(isFirst = button1.isGone && button2.isGone, isLast = true) + } + val dialog = delegate.create() binding.root.tag = dialog - return TwoButtonsAlertDialog(dialog) + return BigButtonsAlertDialog(dialog) + } + + private fun MaterialButton.adjustCorners(isFirst: Boolean, isLast: Boolean) { + if (!isVisible) { + return + } + shapeAppearanceModel = shapeAppearanceModel.toBuilder().apply { + if (!isFirst) { + setTopLeftCornerSize(0f) + setTopRightCornerSize(0f) + } + if (!isLast) { + setBottomLeftCornerSize(0f) + setBottomRightCornerSize(0f) + } + }.build() } private fun initButton( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CommonAlertDialogs.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CommonAlertDialogs.kt new file mode 100644 index 000000000..3eaf94219 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CommonAlertDialogs.kt @@ -0,0 +1,58 @@ +package org.koitharu.kotatsu.core.ui.dialog + +import android.content.Context +import android.content.DialogInterface +import androidx.annotation.UiContext +import androidx.core.net.ConnectivityManagerCompat +import dagger.Lazy +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.TriStateOption +import org.koitharu.kotatsu.core.util.ext.connectivityManager +import javax.inject.Inject + +class CommonAlertDialogs @Inject constructor( + private val settings: Lazy, +) { + + fun askForDownloadOverMeteredNetwork( + @UiContext context: Context, + onConfirmed: (allow: Boolean) -> Unit + ) { + when (settings.get().allowDownloadOnMeteredNetwork) { + TriStateOption.ENABLED -> onConfirmed(true) + TriStateOption.DISABLED -> onConfirmed(false) + TriStateOption.ASK -> { + if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) { + onConfirmed(true) + return + } + val listener = DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED + onConfirmed(true) + } + + DialogInterface.BUTTON_NEUTRAL -> { + onConfirmed(true) + } + + DialogInterface.BUTTON_NEGATIVE -> { + settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED + onConfirmed(false) + } + } + } + BigButtonsAlertDialog.Builder(context) + .setIcon(R.drawable.ic_network_cellular) + .setTitle(R.string.download_cellular_confirm) + .setPositiveButton(R.string.allow_always, listener) + .setNeutralButton(R.string.allow_once, listener) + .setNegativeButton(R.string.dont_allow, listener) + .create() + .show() + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ChipIconTarget.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ChipIconTarget.kt index 82b002b86..1899b7977 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ChipIconTarget.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ChipIconTarget.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.core.ui.image import android.graphics.drawable.Drawable -import coil.target.GenericViewTarget +import coil3.target.GenericViewTarget import com.google.android.material.chip.Chip class ChipIconTarget(override val view: Chip) : GenericViewTarget() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoilImageGetter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoilImageGetter.kt index 7c5a5467f..9ab5efc45 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoilImageGetter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoilImageGetter.kt @@ -4,10 +4,12 @@ import android.content.Context import android.graphics.drawable.Drawable import android.text.Html import androidx.annotation.WorkerThread -import coil.ImageLoader -import coil.executeBlocking -import coil.request.ImageRequest +import coil3.ImageLoader +import coil3.executeBlocking +import coil3.request.ImageRequest +import coil3.request.allowHardware import dagger.hilt.android.qualifiers.ApplicationContext +import org.koitharu.kotatsu.core.util.ext.drawable import javax.inject.Inject class CoilImageGetter @Inject constructor( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt index b7d7bf63e..d90134da7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt @@ -4,9 +4,9 @@ import android.view.View import android.view.View.OnLayoutChangeListener import android.view.ViewGroup import android.widget.ImageView -import coil.size.Dimension -import coil.size.Size -import coil.size.ViewSizeResolver +import coil3.size.Dimension +import coil3.size.Size +import coil3.size.ViewSizeResolver import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt index c0ca38662..b27cc0166 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt @@ -2,11 +2,11 @@ package org.koitharu.kotatsu.core.ui.image import android.graphics.Bitmap import android.media.ThumbnailUtils -import coil.size.Size -import coil.size.pxOrElse -import coil.transform.Transformation +import coil3.size.Size +import coil3.size.pxOrElse +import coil3.transform.Transformation -class ThumbnailTransformation : Transformation { +class ThumbnailTransformation : Transformation() { override val cacheKey: String = javaClass.name @@ -17,8 +17,4 @@ class ThumbnailTransformation : Transformation { size.height.pxOrElse { input.height }, ) } - - override fun equals(other: Any?) = other is ThumbnailTransformation - - override fun hashCode() = javaClass.hashCode() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt index 15695ff11..40d0c2824 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt @@ -2,13 +2,13 @@ package org.koitharu.kotatsu.core.ui.image import android.graphics.Bitmap import androidx.core.graphics.get -import coil.size.Size -import coil.transform.Transformation +import coil3.size.Size +import coil3.transform.Transformation import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame class TrimTransformation( private val tolerance: Int = 20, -) : Transformation { +) : Transformation() { override val cacheKey: String = "${javaClass.name}-$tolerance" @@ -92,12 +92,4 @@ class TrimTransformation( input } } - - override fun equals(other: Any?): Boolean { - return this === other || (other is TrimTransformation && other.tolerance == tolerance) - } - - override fun hashCode(): Int { - return tolerance - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt index fd8d865f8..4239c14c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt @@ -28,6 +28,8 @@ class AdapterDelegateClickListenerAdapter( private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item) + fun attach() = attach(adapterDelegate.itemView) + fun attach(itemView: View) { itemView.setOnClickListener(this) itemView.setOnLongClickListener(this) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index d22681ecb..3a5d98268 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -8,10 +8,16 @@ import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.view.children -import coil.ImageLoader -import coil.request.Disposable -import coil.request.ImageRequest -import coil.transform.RoundedCornersTransformation +import coil3.ImageLoader +import coil3.request.Disposable +import coil3.request.ImageRequest +import coil3.request.allowRgb565 +import coil3.request.crossfade +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder +import coil3.request.transformations +import coil3.transform.RoundedCornersTransformation import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt index 8bf6e5a49..6bffccaa8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt @@ -11,11 +11,13 @@ import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RoundRectShape import android.util.AttributeSet import android.view.LayoutInflater +import android.widget.Checkable import android.widget.LinearLayout import androidx.annotation.AttrRes import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.widget.ImageViewCompat import androidx.core.widget.TextViewCompat @@ -23,6 +25,7 @@ import com.google.android.material.ripple.RippleUtils import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getDrawableCompat import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding @@ -32,7 +35,7 @@ class TwoLinesItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, -) : LinearLayout(context, attrs, defStyleAttr) { +) : LinearLayout(context, attrs, defStyleAttr), Checkable { private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this) @@ -48,6 +51,12 @@ class TwoLinesItemView @JvmOverloads constructor( binding.subtitle.textAndVisible = value } + var isButtonEnabled: Boolean + get() = binding.button.isEnabled + set(value) { + binding.button.isEnabled = value + } + init { var textColors: ColorStateList? = null context.withStyledAttributes( @@ -68,7 +77,7 @@ class TwoLinesItemView @JvmOverloads constructor( binding.layoutText.updateLayoutParams { marginStart = drawablePadding } setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0)) binding.title.text = getText(R.styleable.TwoLinesItemView_title) - binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle) + binding.subtitle.textAndVisible = getText(R.styleable.TwoLinesItemView_subtitle) textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor) val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat TextViewCompat.setTextAppearance( @@ -79,6 +88,10 @@ class TwoLinesItemView @JvmOverloads constructor( binding.subtitle, getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback), ) + binding.icon.isChecked = getBoolean(R.styleable.TwoLinesItemView_android_checked, false) + val button = getDrawableCompat(context, R.styleable.TwoLinesItemView_android_button) + binding.button.setImageDrawable(button) + binding.button.isVisible = button != null } if (textColors == null) { textColors = binding.title.textColors @@ -88,6 +101,16 @@ class TwoLinesItemView @JvmOverloads constructor( ImageViewCompat.setImageTintList(binding.icon, textColors) } + override fun isChecked() = binding.icon.isChecked + + override fun toggle() = binding.icon.toggle() + + override fun setChecked(checked: Boolean) { + binding.icon.isChecked = checked + } + + fun setOnButtonClickListener(listener: OnClickListener?) = binding.button.setOnClickListener(listener) + fun setIconResource(@DrawableRes resId: Int) { binding.icon.setImageResource(resId) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CloseableSequence.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CloseableSequence.kt new file mode 100644 index 000000000..9cf3b317b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CloseableSequence.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.core.util + +interface CloseableSequence : Sequence, AutoCloseable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt index fc486cb4f..3fa1b6e3f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt @@ -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() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt index d86bcd5c2..7f1a649e0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt @@ -7,10 +7,12 @@ import android.app.ActivityManager import android.app.ActivityManager.MemoryInfo import android.app.ActivityOptions import android.app.LocaleConfig +import android.content.ComponentName import android.content.Context import android.content.Context.ACTIVITY_SERVICE import android.content.Context.POWER_SERVICE import android.content.ContextWrapper +import android.content.Intent import android.content.OperationApplicationException import android.content.SharedPreferences import android.content.SyncResult @@ -33,6 +35,7 @@ import androidx.annotation.IntegerRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDialog +import androidx.core.app.ActivityCompat import androidx.core.app.ActivityOptionsCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -61,6 +64,7 @@ import okio.use import org.json.JSONException import org.jsoup.internal.StringUtil.StringJoiner import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException @@ -274,3 +278,10 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) { userAgentString = userAgentOverride } } + +fun Context.restartApplication() { + val activity = findActivity() + val intent = Intent.makeRestartActivityTask(ComponentName(this, MainActivity::class.java)) + startActivity(intent) + activity?.finishAndRemoveTask() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt index 34f3f440e..e89ec9053 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt @@ -14,12 +14,17 @@ import androidx.lifecycle.SavedStateHandle import java.io.Serializable import java.util.EnumSet + // https://issuetracker.google.com/issues/240585930 inline fun Bundle.getParcelableCompat(key: String): T? { return BundleCompat.getParcelable(this, key, T::class.java) } +inline fun Bundle.requireParcelable(key: String): T = checkNotNull(getParcelableCompat(key)) { + "Parcelable of type \"${T::class.java.name}\" not found at \"$key\"" +} + inline fun Intent.getParcelableExtraCompat(key: String): T? { return IntentCompat.getParcelableExtra(this, key, T::class.java) } @@ -84,3 +89,24 @@ fun SavedStateHandle.require(key: String): T { "Value $key not found in SavedStateHandle or has a wrong type" } } + +fun Parcelable.marshall(): ByteArray { + val parcel = Parcel.obtain() + return try { + this.writeToParcel(parcel, 0) + parcel.marshall() + } finally { + parcel.recycle() + } +} + +fun Parcelable.Creator.unmarshall(bytes: ByteArray): T { + val parcel = Parcel.obtain() + return try { + parcel.unmarshall(bytes, 0, bytes.size) + parcel.setDataPosition(0) + createFromParcel(parcel) + } finally { + parcel.recycle() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt index 580b5aeed..6bfbffb75 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt @@ -2,21 +2,37 @@ package org.koitharu.kotatsu.core.util.ext import android.content.Context import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable import android.widget.ImageView import androidx.core.graphics.ColorUtils -import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import coil.request.ErrorResult -import coil.request.ImageRequest -import coil.request.ImageResult -import coil.request.SuccessResult -import coil.util.CoilUtils +import coil3.Extras +import coil3.ImageLoader +import coil3.asDrawable +import coil3.fetch.FetchResult +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.ImageResult +import coil3.request.Options +import coil3.request.SuccessResult +import coil3.request.bitmapConfig +import coil3.request.crossfade +import coil3.request.error +import coil3.request.fallback +import coil3.request.lifecycle +import coil3.request.placeholder +import coil3.request.target +import coil3.size.Scale +import coil3.size.ViewSizeResolver +import coil3.toBitmap +import coil3.util.CoilUtils import com.google.android.material.progressindicator.BaseProgressIndicator import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.core.image.RegionBitmapDecoder import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable -import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener +import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import com.google.android.material.R as materialR @@ -32,6 +48,8 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image .data(data?.takeUnless { it == "" || it == 0 }) .lifecycle(lifecycleOwner) .crossfade(context) + .size(ViewSizeResolver(this)) + .scale(scaleType.toCoilScale()) .target(this) } @@ -43,13 +61,16 @@ fun ImageView.disposeImageRequest() { fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) fun ImageResult.getDrawableOrThrow() = when (this) { - is SuccessResult -> drawable + is SuccessResult -> image.asDrawable(request.context.resources) is ErrorResult -> throw throwable } +val ImageResult.drawable: Drawable? + get() = image?.asDrawable(request.context.resources) + fun ImageResult.toBitmapOrNull() = when (this) { is SuccessResult -> try { - drawable.toBitmap() + image.toBitmap(image.width, image.height, request.bitmapConfig) } catch (_: Throwable) { null } @@ -63,8 +84,10 @@ fun ImageRequest.Builder.indicator(indicators: List>): fun ImageRequest.Builder.decodeRegion( scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED, -): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory()) - .setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll) +): ImageRequest.Builder = apply { + decoderFactory(RegionBitmapDecoder.Factory) + extras[RegionBitmapDecoder.regionScrollKey] = scroll +} @Suppress("SpellCheckingInspection") fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder { @@ -72,8 +95,18 @@ fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder { return crossfade(duration.toInt()) } -fun ImageRequest.Builder.source(source: MangaSource?): ImageRequest.Builder { - return tag(MangaSource::class.java, source) +fun ImageRequest.Builder.mangaSourceExtra(source: MangaSource?): ImageRequest.Builder = apply { + extras[mangaSourceKey] = source +} + +fun ImageRequest.Builder.mangaExtra(manga: Manga): ImageRequest.Builder = apply { + extras[mangaKey] = manga + mangaSourceExtra(manga.source) +} + +fun ImageRequest.Builder.bookmarkExtra(bookmark: Bookmark): ImageRequest.Builder = apply { + extras[bookmarkKey] = bookmark + mangaSourceExtra(bookmark.manga.source) } fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Builder { @@ -87,6 +120,12 @@ fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Bui .error(ColorDrawable(errorColor)) } +private fun ImageView.ScaleType.toCoilScale(): Scale = if (this == ImageView.ScaleType.CENTER_CROP) { + Scale.FILL +} else { + Scale.FIT +} + fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequest.Builder { val existing = build().listener return listener( @@ -98,6 +137,12 @@ fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequ ) } +suspend fun ImageLoader.fetch(data: Any, options: Options): FetchResult? { + val mappedData = components.map(data, options) + val fetcher = components.newFetcher(mappedData, options, this)?.first + return fetcher?.fetch() +} + private class CompositeImageRequestListener( private val delegates: Array, ) : ImageRequest.Listener { @@ -113,3 +158,7 @@ private class CompositeImageRequestListener( operator fun plus(other: ImageRequest.Listener) = CompositeImageRequestListener(delegates + other) } + +val mangaKey = Extras.Key(null) +val bookmarkKey = Extras.Key(null) +val mangaSourceKey = Extras.Key(null) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt index 8af44c883..a7455af10 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt @@ -92,7 +92,7 @@ fun LongSet.toLongArray(): LongArray { return result } -fun LongSet.toSet(): Set = toCollection(ArraySet(size)) +fun LongSet.toSet(): Set = toCollection(ArraySet(size)) fun > LongSet.toCollection(out: R): R = out.also { result -> forEach(result::add) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt index 8618d1ec2..dd6e8e8d9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt @@ -7,16 +7,17 @@ import android.os.Build import android.os.Environment import android.os.storage.StorageManager import android.provider.OpenableColumns +import android.webkit.MimeTypeMap import androidx.core.database.getStringOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible -import okhttp3.internal.closeQuietly +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.fs.FileSequence +import java.io.BufferedReader import java.io.File -import java.io.FileFilter -import java.io.InputStream import java.nio.file.attribute.BasicFileAttributes import java.util.zip.ZipEntry import java.util.zip.ZipFile @@ -36,17 +37,15 @@ fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } fun File.isNotEmpty() = length() != 0L @Blocking -fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use { - it.readText() +fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output -> + output.bufferedReader().use(BufferedReader::readText) } -@Blocking -fun ZipFile.getInputStreamOrClose(entry: ZipEntry): InputStream = try { - getInputStream(entry) -} catch (e: Throwable) { - closeQuietly() - throw e -} +val ZipEntry.mimeType: MediaType? + get() { + val ext = name.substringAfterLast('.') + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.toMediaTypeOrNull() + } fun File.getStorageName(context: Context): String = runCatching { val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager @@ -62,7 +61,7 @@ fun File.getStorageName(context: Context): String = runCatching { } }.getOrNull() ?: context.getString(R.string.other_storage) -fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null +fun Uri.toFileOrNull() = if (isFileUri()) path?.let(::File) else null suspend fun File.deleteAwait() = runInterruptible(Dispatchers.IO) { delete() || deleteRecursively() @@ -87,9 +86,13 @@ suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) { walkCompat(includeDirectories = false).sumOf { it.length() } } -fun File.children() = FileSequence(this) +inline fun File.withChildren(block: (children: Sequence) -> R): R = FileSequence(this).use(block) -fun Sequence.filterWith(filter: FileFilter): Sequence = filter { f -> filter.accept(f) } +fun FileSequence(dir: File): FileSequence = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + FileSequence.StreamImpl(dir) +} else { + FileSequence.ListImpl(dir) +} val File.creationTime get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt index a8db54adc..b5d922e70 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformWhile -import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.SuspendLazy import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/IO.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/IO.kt index d41e0ba38..8cce14a33 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/IO.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/IO.kt @@ -7,9 +7,15 @@ 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 +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.nio.ByteBuffer fun ResponseBody.withProgress(progressState: MutableStateFlow): ResponseBody { return ProgressResponseBody(this, progressState) @@ -23,3 +29,22 @@ suspend fun Source.cancellable(): Source { suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { writeAll(source.cancellable()) } + +fun InputStream.toByteBuffer(): ByteBuffer { + val outStream = ByteArrayOutputStream(available()) + copyTo(outStream) + 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 +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt index 0dd4d0cf2..e2170048f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt @@ -37,13 +37,6 @@ val RecyclerView.visibleItemCount: Int findLastVisibleItemPosition() - findFirstVisibleItemPosition() } ?: 0 -fun RecyclerView.findCenterViewPosition(): Int { - val centerX = width / 2f - val centerY = height / 2f - val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION - return getChildAdapterPosition(view) -} - fun RecyclerView.ViewHolder.getItem(clazz: Class): T? { val rawItem = when (this) { is AdapterDelegateViewBindingViewHolder<*, *> -> item diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt index 1b4917bd4..3294e66fb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt @@ -28,6 +28,8 @@ fun String.toUUIDOrNull(): UUID? = try { null } +fun String.digits() = filter { it.isDigit() } + /** * @param threshold 0 = exact match */ diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt index e5b713d8f..185c3af4a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt @@ -29,7 +29,7 @@ fun Context.getThemeColor( @Px fun Context.getThemeDimensionPixelSize( @AttrRes resId: Int, - @ColorInt fallback: Int = 0, + @Px fallback: Int = 0, ) = obtainStyledAttributes(intArrayOf(resId)).use { it.getDimensionPixelSize(0, fallback) } @@ -37,7 +37,7 @@ fun Context.getThemeDimensionPixelSize( @Px fun Context.getThemeDimensionPixelOffset( @AttrRes resId: Int, - @ColorInt fallback: Int = 0, + @Px fallback: Int = 0, ) = obtainStyledAttributes(intArrayOf(resId)).use { it.getDimensionPixelOffset(0, fallback) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index 93c447518..1e43a3055 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -3,8 +3,9 @@ package org.koitharu.kotatsu.core.util.ext import android.content.ActivityNotFoundException import android.content.res.Resources import androidx.annotation.DrawableRes -import coil.network.HttpException +import coil3.network.HttpException import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException +import okhttp3.Response import okio.FileNotFoundException import okio.IOException import okio.ProtocolException @@ -24,6 +25,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 @@ -35,13 +37,20 @@ 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.net.ConnectException +import java.net.NoRouteToHostException +import java.io.ObjectOutputStream 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), @@ -78,12 +87,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_corrupted_file) + 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) @@ -92,9 +117,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) { @@ -102,6 +125,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 @@ -109,8 +134,21 @@ fun Throwable.getDisplayIcon() = when (this) { else -> R.drawable.ic_error_large } +fun Throwable.getCauseUrl(): String? = when (this) { + is ParseException -> url + is NotFoundException -> url + is TooManyRequestExceptions -> url + is CaughtException -> cause?.getCauseUrl() + is CloudFlareBlockedException -> url + is CloudFlareProtectedException -> url + is HttpStatusException -> url + is HttpException -> (response.delegate as? Response)?.request?.url?.toString() + else -> null +} + 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 } @@ -143,6 +181,8 @@ fun Throwable.isReportable(): Boolean { || this is CloudFlareProtectedException || this is BadBackupFormatException || this is WrongPasswordException + || this is TooManyRequestExceptions + || this is HttpStatusException ) { return false } @@ -165,3 +205,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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt index 152aebd14..4bd885bef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt @@ -1,56 +1,38 @@ package org.koitharu.kotatsu.core.util.ext import android.net.Uri -import androidx.core.net.toFile -import okio.Source -import okio.source -import okio.use -import org.jetbrains.annotations.Blocking -import org.koitharu.kotatsu.local.data.util.withExtraCloseable +import androidx.core.net.toUri +import okio.Path import java.io.File -import java.util.zip.ZipFile -const val URI_SCHEME_FILE = "file" const val URI_SCHEME_ZIP = "file+zip" - -@Blocking -fun Uri.exists(): Boolean = when (scheme) { - URI_SCHEME_FILE -> toFile().exists() - URI_SCHEME_ZIP -> { - val file = File(requireNotNull(schemeSpecificPart)) - file.exists() && ZipFile(file).use { it.getEntry(fragment) != null } - } - - else -> unsupportedUri(this) +private const val URI_SCHEME_FILE = "file" +private const val URI_SCHEME_HTTP = "http" +private const val URI_SCHEME_HTTPS = "https" +private const val URI_SCHEME_LEGACY_CBZ = "cbz" +private const val URI_SCHEME_LEGACY_ZIP = "zip" + +fun Uri.isZipUri() = scheme.let { + it == URI_SCHEME_ZIP || it == URI_SCHEME_LEGACY_CBZ || it == URI_SCHEME_LEGACY_ZIP } -@Blocking -fun Uri.isTargetNotEmpty(): Boolean = when (scheme) { - URI_SCHEME_FILE -> toFile().isNotEmpty() - URI_SCHEME_ZIP -> { - val file = File(requireNotNull(schemeSpecificPart)) - file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L } - } +fun Uri.isFileUri() = scheme == URI_SCHEME_FILE - else -> unsupportedUri(this) +fun Uri.isNetworkUri() = scheme.let { + it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS } -@Blocking -fun Uri.source(): Source = when (scheme) { - URI_SCHEME_FILE -> toFile().source() - URI_SCHEME_ZIP -> { - val zip = ZipFile(schemeSpecificPart) - val entry = zip.getEntry(fragment) - zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip) - } - - else -> unsupportedUri(this) -} +fun File.toZipUri(entryPath: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryPath") -fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName") +fun File.toZipUri(entryPath: Path?): Uri = + toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty()) fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) -private fun unsupportedUri(uri: Uri): Nothing { - throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported") +fun File.toUri(fragment: String?): Uri = toUri().run { + if (fragment != null) { + buildUpon().fragment(fragment).build() + } else { + this + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/WorkManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/WorkManager.kt index d83a4f560..f6e04b8b6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/WorkManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/WorkManager.kt @@ -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 { - return getWorkInfosForUniqueWork(name).await().orEmpty() + return getWorkInfosForUniqueWork(name).await() } @SuppressLint("RestrictedApi") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/CloseableIterator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/CloseableIterator.kt deleted file mode 100644 index da59d5efc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/CloseableIterator.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.koitharu.kotatsu.core.util.iterator - -import okhttp3.internal.closeQuietly -import okio.Closeable - -class CloseableIterator( - private val upstream: Iterator, - private val closeable: Closeable, -) : Iterator, Closeable { - - private var isClosed = false - - override fun hasNext(): Boolean { - val result = upstream.hasNext() - if (!result) { - close() - } - return result - } - - override fun next(): T { - try { - return upstream.next() - } catch (e: NoSuchElementException) { - close() - throw e - } - } - - override fun close() { - if (!isClosed) { - closeable.closeQuietly() - isClosed = true - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ImageRequestIndicatorListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ImageRequestIndicatorListener.kt index 5b2d5bee8..fbc69a11d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ImageRequestIndicatorListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ImageRequestIndicatorListener.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.core.util.progress -import coil.request.ErrorResult -import coil.request.ImageRequest -import coil.request.SuccessResult +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.SuccessResult import com.google.android.material.progressindicator.BaseProgressIndicator class ImageRequestIndicatorListener( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressResponseBody.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressResponseBody.kt index b66e5cd2a..06a3fea9b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressResponseBody.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressResponseBody.kt @@ -26,8 +26,10 @@ class ProgressResponseBody( override fun contentType(): MediaType? = delegate.contentType() override fun source(): BufferedSource { - return bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also { - bufferedSource = it + return bufferedSource ?: synchronized(this) { + bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also { + bufferedSource = it + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt index 448341678..509ff3287 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -2,10 +2,13 @@ package org.koitharu.kotatsu.core.zip import androidx.annotation.WorkerThread import androidx.collection.ArraySet +import okhttp3.internal.closeQuietly import okio.Closeable -import org.koitharu.kotatsu.core.util.ext.children +import org.jetbrains.annotations.Blocking +import org.koitharu.kotatsu.core.util.ext.withChildren import java.io.File import java.io.FileInputStream +import java.io.FileOutputStream import java.util.zip.Deflater import java.util.zip.ZipEntry import java.util.zip.ZipFile @@ -13,26 +16,23 @@ import java.util.zip.ZipOutputStream class ZipOutput( val file: File, - compressionLevel: Int = Deflater.DEFAULT_COMPRESSION, + private val compressionLevel: Int = Deflater.DEFAULT_COMPRESSION, ) : Closeable { private val entryNames = ArraySet() - private var isClosed = false - private val output = ZipOutputStream(file.outputStream()).apply { - setLevel(compressionLevel) - } + private var cachedOutput: ZipOutputStream? = null - @WorkerThread - fun put(name: String, file: File): Boolean { - return output.appendFile(file, name) + @Blocking + fun put(name: String, file: File): Boolean = withOutput { output -> + output.appendFile(file, name) } - @WorkerThread - fun put(name: String, content: String): Boolean { - return output.appendText(content, name) + @Blocking + fun put(name: String, content: String): Boolean = withOutput { output -> + output.appendText(content, name) } - @WorkerThread + @Blocking fun addDirectory(name: String): Boolean { val entry = if (name.endsWith("/")) { ZipEntry(name) @@ -40,25 +40,29 @@ class ZipOutput( ZipEntry("$name/") } return if (entryNames.add(entry.name)) { - output.putNextEntry(entry) - output.closeEntry() + withOutput { output -> + output.putNextEntry(entry) + output.closeEntry() + } true } else { false } } - @WorkerThread + @Blocking 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) + withOutput { output -> + output.putNextEntry(zipEntry) + try { + other.getInputStream(entry).use { input -> + input.copyTo(output) + } + } finally { + output.closeEntry() } - } finally { - output.closeEntry() } true } else { @@ -66,16 +70,15 @@ class ZipOutput( } } - fun finish() { + @Blocking + fun finish() = withOutput { output -> output.finish() - output.flush() } + @Synchronized override fun close() { - if (!isClosed) { - output.close() - isClosed = true - } + cachedOutput?.close() + cachedOutput = null } @WorkerThread @@ -91,8 +94,10 @@ class ZipOutput( } putNextEntry(entry) closeEntry() - fileToZip.children().forEach { childFile -> - appendFile(childFile, "$name/${childFile.name}") + fileToZip.withChildren { children -> + children.forEach { childFile -> + appendFile(childFile, "$name/${childFile.name}") + } } } else { FileInputStream(fileToZip).use { fis -> @@ -101,8 +106,11 @@ class ZipOutput( } val zipEntry = ZipEntry(name) putNextEntry(zipEntry) - fis.copyTo(this) - closeEntry() + try { + fis.copyTo(this) + } finally { + closeEntry() + } } } return true @@ -115,8 +123,25 @@ class ZipOutput( } val zipEntry = ZipEntry(name) putNextEntry(zipEntry) - content.byteInputStream().copyTo(this) - closeEntry() + try { + content.byteInputStream().copyTo(this) + } finally { + closeEntry() + } return true } + + @Synchronized + private fun withOutput(block: (ZipOutputStream) -> T): T { + val output = cachedOutput ?: newOutput(append = false) + val res = block(output) + output.flush() + return res + } + + private fun newOutput(append: Boolean) = ZipOutputStream(FileOutputStream(file, append)).also { + it.setLevel(compressionLevel) + cachedOutput?.closeQuietly() + cachedOutput = it + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt index 1caca8498..bf0d17f9e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt @@ -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(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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 951c479db..7b9cb3e8c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -26,11 +26,20 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.swiperefreshlayout.widget.CircularProgressDrawable -import coil.ImageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.transform.RoundedCornersTransformation -import coil.util.CoilUtils +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowRgb565 +import coil3.request.crossfade +import coil3.request.error +import coil3.request.fallback +import coil3.request.lifecycle +import coil3.request.placeholder +import coil3.request.target +import coil3.request.transformations +import coil3.size.Scale +import coil3.transform.RoundedCornersTransformation +import coil3.util.CoilUtils import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint @@ -65,18 +74,19 @@ import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders +import org.koitharu.kotatsu.core.util.ext.drawable import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.isTextTruncated import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.details.data.MangaDetails @@ -88,6 +98,7 @@ import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter +import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.image.ui.ImageActivity @@ -195,6 +206,7 @@ class DetailsActivity : .filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) } .observeEvent(this, DownloadStartedObserver(viewBinding.scrollView)) + DownloadDialogFragment.registerCallback(this, viewBinding.scrollView) menuProvider = DetailsMenuProvider( activity = this, viewModel = viewModel, @@ -210,7 +222,10 @@ class DetailsActivity : when (v.id) { R.id.button_read -> openReader(isIncognitoMode = false) R.id.chip_branch -> showBranchPopupMenu(v) - R.id.button_download -> DownloadDialogHelper(v, viewModel).show(menuProvider) + R.id.button_download -> { + val manga = viewModel.manga.value ?: return + DownloadDialogFragment.show(supportFragmentManager, listOf(manga)) + } R.id.chip_author -> { val manga = viewModel.manga.value ?: return @@ -480,7 +495,7 @@ class DetailsActivity : .placeholder(R.drawable.ic_web) .fallback(R.drawable.ic_web) .error(R.drawable.ic_web) - .source(manga.source) + .mangaSourceExtra(manga.source) .transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner))) .allowRgb565(true) .enqueueWith(coil) @@ -616,8 +631,9 @@ class DetailsActivity : val request = ImageRequest.Builder(this) .target(viewBinding.imageViewCover) .size(CoverSizeResolver(viewBinding.imageViewCover)) + .scale(Scale.FILL) .data(imageUrl) - .tag(manga.source) + .mangaSourceExtra(manga.source) .crossfade(this) .lifecycle(this) .placeholderMemoryCacheKey(manga.coverUrl) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt index 482618a0a..745703330 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt @@ -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) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt index 0cc332f30..0f7765a87 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt @@ -19,9 +19,8 @@ import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.os.AppShortcutManager -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ShareHelper -import org.koitharu.kotatsu.download.ui.dialog.DownloadOption +import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.search.ui.multi.SearchActivity import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet @@ -31,7 +30,7 @@ class DetailsMenuProvider( private val viewModel: DetailsViewModel, private val snackbarHost: View, private val appShortcutManager: AppShortcutManager, -) : MenuProvider, OnListItemClickListener { +) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_details, menu) @@ -75,7 +74,7 @@ class DetailsMenuProvider( } R.id.action_save -> { - DownloadDialogHelper(snackbarHost, viewModel).show(this) + DownloadDialogFragment.show(activity.supportFragmentManager, listOfNotNull(viewModel.manga.value)) } R.id.action_browser -> { @@ -129,17 +128,4 @@ class DetailsMenuProvider( } return true } - - override fun onItemClick(item: DownloadOption, view: View) { - val chaptersIds: Set? = when (item) { - is DownloadOption.WholeManga -> null - is DownloadOption.SelectionHint -> { - viewModel.startChaptersSelection() - return - } - - else -> item.chaptersIds - } - viewModel.download(chaptersIds) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt deleted file mode 100644 index 50f8fe3d9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import android.content.DialogInterface -import android.view.View -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.ids -import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog -import org.koitharu.kotatsu.core.ui.dialog.setRecyclerViewList -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.download.ui.dialog.DownloadOption -import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD -import org.koitharu.kotatsu.settings.SettingsActivity - -class DownloadDialogHelper( - private val host: View, - private val viewModel: DetailsViewModel, -) { - - fun show(callback: OnListItemClickListener) { - val branch = viewModel.selectedBranchValue - val allChapters = viewModel.manga.value?.chapters ?: return - val branchChapters = viewModel.manga.value?.getChapters(branch).orEmpty() - val history = viewModel.history.value - - val options = buildList { - add(DownloadOption.WholeManga(allChapters.ids())) - if (branch != null && branchChapters.isNotEmpty()) { - add(DownloadOption.AllChapters(branch, branchChapters.ids())) - } - - if (history != null) { - val unreadChapters = branchChapters.dropWhile { it.id != history.chapterId } - if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) { - add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch)) - if (unreadChapters.size > 5) { - add(DownloadOption.NextUnreadChapters(unreadChapters.take(5).ids())) - if (unreadChapters.size > 10) { - add(DownloadOption.NextUnreadChapters(unreadChapters.take(10).ids())) - } - } - } - } else { - if (branchChapters.size > 5) { - add(DownloadOption.FirstChapters(branchChapters.take(5).ids())) - if (branchChapters.size > 10) { - add(DownloadOption.FirstChapters(branchChapters.take(10).ids())) - } - } - } - add(DownloadOption.SelectionHint()) - } - var dialog: DialogInterface? = null - val listener = OnListItemClickListener { item, _ -> - callback.onItemClick(item, host) - dialog?.dismiss() - } - dialog = buildAlertDialog(host.context) { - setCancelable(true) - setTitle(R.string.download) - setNegativeButton(android.R.string.cancel, null) - setNeutralButton(R.string.settings) { _, _ -> - host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context)) - } - setRecyclerViewList(options, downloadOptionAD(listener)) - }.also { it.show() } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt index 5035401c2..ae83503c1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt @@ -12,7 +12,7 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel class ChaptersAdapter( - private val onItemClickListener: OnListItemClickListener, + onItemClickListener: OnListItemClickListener, ) : BaseListAdapter(), FastScroller.SectionIndexer { private var hasVolumes = false diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt index 8dca45cf1..b4e013441 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt @@ -33,6 +33,7 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.mapChapters import org.koitharu.kotatsu.details.ui.model.ChapterListItem +import org.koitharu.kotatsu.download.ui.worker.DownloadTask import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase @@ -163,13 +164,18 @@ abstract class ChaptersPagesViewModel( } } - fun download(chaptersIds: Set?) { + fun download(chaptersIds: Set?, allowMeteredNetwork: Boolean) { launchJob(Dispatchers.Default) { - downloadScheduler.schedule( - manga = requireManga(), - chaptersIds = chaptersIds, + val task = DownloadTask( + mangaId = requireManga().id, + isPaused = false, isSilent = false, + chaptersIds = chaptersIds?.toLongArray(), + destination = null, + format = null, + allowMeteredNetwork = allowMeteredNetwork, ) + downloadScheduler.schedule(setOf(task)) onDownloadStarted.call(Unit) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt index faa8c4069..b554b645a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt @@ -11,7 +11,7 @@ import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt index de808b1b4..0c82e7c83 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseFragment +import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper @@ -38,6 +39,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderState +import javax.inject.Inject import kotlin.math.roundToInt @AndroidEntryPoint @@ -47,6 +49,9 @@ class ChaptersFragment : private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this) + @Inject + lateinit var commonAlertDialogs: CommonAlertDialogs + private var chaptersAdapter: ChaptersAdapter? = null private var selectionController: ListSelectionController? = null @@ -62,7 +67,7 @@ class ChaptersFragment : appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = ChaptersSelectionDecoration(binding.root.context), registryOwner = this, - callback = ChaptersSelectionCallback(viewModel, binding.recyclerViewChapters), + callback = ChaptersSelectionCallback(viewModel, commonAlertDialogs, binding.recyclerViewChapters), ) viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView -> binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt index a5cb76c3c..4c4925d75 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt @@ -8,6 +8,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource +import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.toCollection @@ -17,6 +18,7 @@ import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService class ChaptersSelectionCallback( private val viewModel: ChaptersPagesViewModel, + private val commonAlertDialogs: CommonAlertDialogs, recyclerView: RecyclerView, ) : BaseListSelectionCallback(recyclerView) { @@ -58,8 +60,14 @@ class ChaptersSelectionCallback( override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_save -> { - viewModel.download(controller.snapshot()) + val snapshot = controller.snapshot() mode?.finish() + if (snapshot.isNotEmpty()) { + commonAlertDialogs.askForDownloadOverMeteredNetwork( + context = recyclerView.context, + onConfirmed = { viewModel.download(snapshot, it) }, + ) + } true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt index 344a54004..e63304849 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt @@ -1,59 +1,53 @@ package org.koitharu.kotatsu.details.ui.pager.pages -import android.content.Context import android.webkit.MimeTypeMap -import androidx.core.net.toFile import androidx.core.net.toUri -import coil.ImageLoader -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.network.HttpException -import coil.request.Options -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.network.HttpException +import coil3.network.NetworkHeaders +import coil3.network.NetworkResponse +import coil3.network.NetworkResponseBody +import coil3.request.Options +import okhttp3.Headers import okhttp3.OkHttpClient +import okhttp3.Response +import okio.FileSystem import okio.Path.Companion.toOkioPath -import okio.buffer -import okio.source import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.util.ext.getInputStreamOrClose +import org.koitharu.kotatsu.core.util.ext.fetch +import org.koitharu.kotatsu.core.util.ext.isNetworkUri import org.koitharu.kotatsu.local.data.PagesCache -import org.koitharu.kotatsu.local.data.isFileUri -import org.koitharu.kotatsu.local.data.isZipUri -import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.mimeType +import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.reader.domain.PageLoader -import java.util.zip.ZipFile import javax.inject.Inject class MangaPageFetcher( - private val context: Context, private val okHttpClient: OkHttpClient, private val pagesCache: PagesCache, private val options: Options, private val page: MangaPage, private val mangaRepositoryFactory: MangaRepository.Factory, private val imageProxyInterceptor: ImageProxyInterceptor, + private val imageLoader: ImageLoader, ) : Fetcher { - override suspend fun fetch(): FetchResult { + override suspend fun fetch(): FetchResult? { val repo = mangaRepositoryFactory.create(page.source) val pageUrl = repo.getPageUrl(page) if (options.diskCachePolicy.readEnabled) { pagesCache.get(pageUrl)?.let { file -> - return SourceResult( - source = ImageSource( - file = file.toOkioPath(), - metadata = MangaPageMetadata(page), - ), - mimeType = null, + return SourceFetchResult( + source = ImageSource(file.toOkioPath(), options.fileSystem), + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension), dataSource = DataSource.DISK, ) } @@ -61,65 +55,48 @@ class MangaPageFetcher( return loadPage(pageUrl) } - private suspend fun loadPage(pageUrl: String): SourceResult { - val uri = pageUrl.toUri() - return when { - uri.isZipUri() -> runInterruptible(Dispatchers.IO) { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - SourceResult( - source = ImageSource( - source = zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip).buffer(), - context = context, - metadata = MangaPageMetadata(page), - ), - mimeType = MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(entry.name.substringAfterLast('.', "")), - dataSource = DataSource.DISK, - ) - } + private suspend fun loadPage(pageUrl: String): FetchResult? = if (pageUrl.toUri().isNetworkUri()) { + fetchPage(pageUrl) + } else { + imageLoader.fetch(pageUrl, options) + } - uri.isFileUri() -> runInterruptible(Dispatchers.IO) { - val file = uri.toFile() - SourceResult( - source = ImageSource( - source = file.source().buffer(), - context = context, - metadata = MangaPageMetadata(page), - ), - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension), - dataSource = DataSource.DISK, - ) + private suspend fun fetchPage(pageUrl: String): FetchResult { + val request = PageLoader.createPageRequest(pageUrl, page.source) + return imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response -> + if (!response.isSuccessful) { + throw HttpException(response.toNetworkResponse()) } - - else -> { - val request = PageLoader.createPageRequest(pageUrl, page.source) - imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response -> - if (!response.isSuccessful) { - throw HttpException(response) - } - val body = checkNotNull(response.body) { - "Null response" - } - val mimeType = response.mimeType - val file = body.use { - pagesCache.put(pageUrl, it.source()) - } - SourceResult( - source = ImageSource( - file = file.toOkioPath(), - metadata = MangaPageMetadata(page), - ), - mimeType = mimeType, - dataSource = DataSource.NETWORK, - ) - } + val mimeType = response.mimeType + val file = response.requireBody().use { + pagesCache.put(pageUrl, it.source(), mimeType) } + SourceFetchResult( + source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM), + mimeType = mimeType, + dataSource = DataSource.NETWORK, + ) } } + 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() + for ((key, values) in this) { + headers.add(key, values) + } + return headers.build() + } + class Factory @Inject constructor( - @ApplicationContext private val context: Context, @MangaHttpClient private val okHttpClient: OkHttpClient, private val pagesCache: PagesCache, private val mangaRepositoryFactory: MangaRepository.Factory, @@ -131,11 +108,9 @@ class MangaPageFetcher( pagesCache = pagesCache, options = options, page = data, - context = context, mangaRepositoryFactory = mangaRepositoryFactory, imageProxyInterceptor = imageProxyInterceptor, + imageLoader = imageLoader, ) } - - class MangaPageMetadata(val page: MangaPage) : ImageSource.Metadata() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageKeyer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageKeyer.kt index 6895c6ad8..8f137beed 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageKeyer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageKeyer.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.details.ui.pager.pages -import coil.key.Keyer -import coil.request.Options +import coil3.key.Keyer +import coil3.request.Options import org.koitharu.kotatsu.parsers.model.MangaPage class MangaPageKeyer : Keyer { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt index 832a6df67..494cbfdd7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt @@ -1,9 +1,10 @@ package org.koitharu.kotatsu.details.ui.pager.pages import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import coil.size.Scale -import coil.size.Size +import coil3.ImageLoader +import coil3.request.allowRgb565 +import coil3.size.Scale +import coil3.size.Size import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter @@ -11,9 +12,9 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.setTextColorAttr -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemPageThumbBinding import org.koitharu.kotatsu.list.ui.model.ListModel import com.google.android.material.R as materialR @@ -42,13 +43,13 @@ fun pageThumbnailAD( scale(Scale.FILL) allowRgb565(true) decodeRegion(0) - source(item.page.source) + mangaSourceExtra(item.page.source) enqueueWith(coil) } with(binding.textViewNumber) { setBackgroundResource(if (item.isCurrent) R.drawable.bg_badge_accent else R.drawable.bg_badge_empty) setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary) - text = (item.number).toString() + text = item.number.toString() } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAdapter.kt index 5c66f2d7c..923c7776b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAdapter.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.details.ui.pager.pages import android.content.Context import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt index 9ed57da9e..0edc18907 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt @@ -10,7 +10,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt index 83326baff..6b629ec8d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.details.ui.scrobbling import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt index de96a0a5a..69b25e63e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt @@ -14,7 +14,7 @@ import androidx.core.net.toUri import androidx.core.text.method.LinkMovementMethodCompat import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt index aa36cb8c1..fe205632a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.details.ui.scrobbling import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.list.ui.model.ListModel diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChapterSelectOptions.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChapterSelectOptions.kt new file mode 100644 index 000000000..6a4ff0c6f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChapterSelectOptions.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.download.ui.dialog + +data class ChapterSelectOptions( + val wholeManga: ChaptersSelectMacro.WholeManga, + val wholeBranch: ChaptersSelectMacro.WholeBranch?, + val firstChapters: ChaptersSelectMacro.FirstChapters?, + val unreadChapters: ChaptersSelectMacro.UnreadChapters?, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChaptersSelectMacro.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChaptersSelectMacro.kt new file mode 100644 index 000000000..5302df6d3 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/ChaptersSelectMacro.kt @@ -0,0 +1,97 @@ +package org.koitharu.kotatsu.download.ui.dialog + +import androidx.collection.ArraySet +import androidx.collection.LongLongMap +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.mapNotNullToSet + +interface ChaptersSelectMacro { + + fun getChaptersIds(mangaId: Long, chapters: List): Set? + + class WholeManga( + val chaptersCount: Int, + ) : ChaptersSelectMacro { + + override fun getChaptersIds(mangaId: Long, chapters: List): Set? = null + } + + class WholeBranch( + val branches: Map, + val selectedBranch: String?, + ) : ChaptersSelectMacro { + + val chaptersCount: Int = branches[selectedBranch] ?: 0 + + override fun getChaptersIds( + mangaId: Long, + chapters: List + ): Set = chapters.mapNotNullToSet { c -> + if (c.branch == selectedBranch) { + c.id + } else { + null + } + } + + fun copy(branch: String?) = WholeBranch(branches, branch) + } + + class FirstChapters( + val chaptersCount: Int, + val maxAvailableCount: Int, + val branch: String?, + ) : ChaptersSelectMacro { + + override fun getChaptersIds(mangaId: Long, chapters: List): Set { + val result = ArraySet(chaptersCount) + for (c in chapters) { + if (c.branch == branch) { + result.add(c.id) + if (result.size >= chaptersCount) { + break + } + } + } + return result + } + + fun copy(count: Int) = FirstChapters(count, maxAvailableCount, branch) + } + + class UnreadChapters( + val chaptersCount: Int, + val maxAvailableCount: Int, + private val currentChaptersIds: LongLongMap, + ) : ChaptersSelectMacro { + + override fun getChaptersIds(mangaId: Long, chapters: List): Set? { + if (chapters.isEmpty()) { + return null + } + val currentChapterId = currentChaptersIds.getOrDefault(mangaId, chapters.first().id) + var branch: String? = null + var isAdding = false + val result = ArraySet(chaptersCount) + for (c in chapters) { + if (!isAdding) { + if (c.id == currentChapterId) { + branch = c.branch + isAdding = true + } + } + if (isAdding) { + if (c.branch == branch) { + result.add(c.id) + if (result.size >= chaptersCount) { + break + } + } + } + } + return result + } + + fun copy(count: Int) = UnreadChapters(count, maxAvailableCount, currentChaptersIds) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DestinationsAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DestinationsAdapter.kt new file mode 100644 index 000000000..bb9a34b88 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DestinationsAdapter.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.download.ui.dialog + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import androidx.core.view.isVisible +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.textAndVisible +import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding +import org.koitharu.kotatsu.settings.storage.DirectoryModel + +class DestinationsAdapter(context: Context, dataset: List) : + ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1, dataset) { + + init { + setDropDownViewResource(R.layout.item_storage_config) + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(parent.context) + .inflate(android.R.layout.simple_spinner_dropdown_item, parent, false) + val item = getItem(position) ?: return view + view.findViewById(android.R.id.text1).text = item.title ?: view.context.getString(item.titleRes) + return view + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(parent.context) + .inflate(R.layout.item_storage_config, parent, false) + val item = getItem(position) ?: return view + val binding = + view.tag as? ItemStorageConfigBinding ?: ItemStorageConfigBinding.bind(view).also { view.tag = it } + binding.imageViewRemove.isVisible = false + binding.textViewTitle.text = item.title ?: view.context.getString(item.titleRes) + binding.textViewSubtitle.textAndVisible = item.file?.path + return view + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt new file mode 100644 index 000000000..e0f6cee7c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt @@ -0,0 +1,372 @@ +package org.koitharu.kotatsu.download.ui.dialog + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.View +import android.view.ViewGroup +import android.widget.Spinner +import androidx.appcompat.widget.PopupMenu +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentResultListener +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.prefs.DownloadFormat +import org.koitharu.kotatsu.core.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs +import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView +import org.koitharu.kotatsu.core.util.ext.findActivity +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit +import org.koitharu.kotatsu.core.util.ext.mapToArray +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.parentView +import org.koitharu.kotatsu.core.util.ext.showDistinct +import org.koitharu.kotatsu.core.util.ext.showOrHide +import org.koitharu.kotatsu.core.util.ext.withArgs +import org.koitharu.kotatsu.databinding.DialogDownloadBinding +import org.koitharu.kotatsu.download.ui.list.DownloadsActivity +import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.format +import org.koitharu.kotatsu.settings.storage.DirectoryModel +import javax.inject.Inject + +@AndroidEntryPoint +class DownloadDialogFragment : AlertDialogFragment(), View.OnClickListener { + + private val viewModel by viewModels() + private var optionViews: Array? = null + + @Inject + lateinit var commonAlertDialogs: CommonAlertDialogs + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?) = + DialogDownloadBinding.inflate(inflater, container, false) + + override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { + return super.onBuildDialog(builder) + .setTitle(R.string.save_manga) + .setCancelable(true) + } + + override fun onViewBindingCreated(binding: DialogDownloadBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + optionViews = arrayOf( + binding.optionWholeManga, + binding.optionWholeBranch, + binding.optionFirstChapters, + binding.optionUnreadChapters, + ).onEach { + it.setOnClickListener(this) + it.setOnButtonClickListener(this) + } + binding.buttonCancel.setOnClickListener(this) + binding.buttonConfirm.setOnClickListener(this) + binding.textViewMore.setOnClickListener(this) + + binding.textViewSummary.text = viewModel.manga.joinToStringWithLimit(binding.root.context, 120) { it.title } + + viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) + viewModel.onScheduled.observeEvent(viewLifecycleOwner, this::onDownloadScheduled) + viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) + viewModel.defaultFormat.observe(viewLifecycleOwner, this::onDefaultFormatChanged) + viewModel.availableDestinations.observe(viewLifecycleOwner, this::onDestinationsChanged) + viewModel.chaptersSelectOptions.observe(viewLifecycleOwner, this::onChapterSelectOptionsChanged) + viewModel.isOptionsLoading.observe(viewLifecycleOwner, binding.progressBar::showOrHide) + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + showMoreOptions(requireViewBinding().textViewMore.isChecked) + setCheckedOption( + savedInstanceState?.getInt(KEY_CHECKED_OPTION, R.id.option_whole_manga) ?: R.id.option_whole_manga, + ) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + optionViews?.find { it.isChecked }?.let { + outState.putInt(KEY_CHECKED_OPTION, it.id) + } + } + + override fun onDestroyView() { + super.onDestroyView() + optionViews = null + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_cancel -> dialog?.cancel() + R.id.button_confirm -> commonAlertDialogs.askForDownloadOverMeteredNetwork( + context = context ?: return, + onConfirmed = ::schedule, + ) + + R.id.textView_more -> { + val binding = viewBinding ?: return + binding.textViewMore.toggle() + showMoreOptions(binding.textViewMore.isChecked) + } + + R.id.button -> when (v.parentView?.id ?: return) { + R.id.option_whole_branch -> showBranchSelection(v) + R.id.option_first_chapters -> showFirstChaptersCountSelection(v) + R.id.option_unread_chapters -> showUnreadChaptersCountSelection(v) + } + + else -> if (v is TwoLinesItemView) { + setCheckedOption(v.id) + } + } + } + + private fun schedule(allowMeteredNetwork: Boolean) { + viewBinding?.run { + val options = viewModel.chaptersSelectOptions.value + viewModel.confirm( + startNow = switchStart.isChecked, + chaptersMacro = when { + optionWholeManga.isChecked -> options.wholeManga + optionWholeBranch.isChecked -> options.wholeBranch ?: return@run + optionFirstChapters.isChecked -> options.firstChapters ?: return@run + optionUnreadChapters.isChecked -> options.unreadChapters ?: return@run + else -> return@run + }, + format = DownloadFormat.entries.getOrNull(spinnerFormat.selectedItemPosition), + destination = viewModel.availableDestinations.value.getOrNull(spinnerDestination.selectedItemPosition), + allowMetered = allowMeteredNetwork, + ) + } + } + + private fun onError(e: Throwable) { + MaterialAlertDialogBuilder(context ?: return) + .setNegativeButton(R.string.close, null) + .setTitle(R.string.error) + .setMessage(e.getDisplayMessage(resources)) + .show() + dismiss() + } + + private fun onLoadingStateChanged(value: Boolean) { + with(requireViewBinding()) { + buttonConfirm.isEnabled = !value + } + } + + private fun onDefaultFormatChanged(format: DownloadFormat?) { + val spinner = viewBinding?.spinnerFormat ?: return + spinner.setSelection(format?.ordinal ?: Spinner.INVALID_POSITION) + } + + private fun onDestinationsChanged(directories: List) { + viewBinding?.spinnerDestination?.run { + adapter = DestinationsAdapter(context, directories) + setSelection(directories.indexOfFirst { it.isChecked }) + } + } + + private fun onChapterSelectOptionsChanged(options: ChapterSelectOptions) { + with(viewBinding ?: return) { + // Whole manga + optionWholeManga.subtitle = if (options.wholeManga.chaptersCount > 0) { + resources.getQuantityString( + R.plurals.chapters, + options.wholeManga.chaptersCount, + options.wholeManga.chaptersCount, + ) + } else { + null + } + // All chapters for branch + optionWholeBranch.isVisible = options.wholeBranch != null + options.wholeBranch?.let { + optionWholeBranch.title = resources.getString( + R.string.download_option_all_chapters, + it.selectedBranch, + ) + optionWholeBranch.subtitle = if (it.chaptersCount > 0) { + resources.getQuantityString( + R.plurals.chapters, + it.chaptersCount, + it.chaptersCount, + ) + } else { + null + } + } + // First N chapters + optionFirstChapters.isVisible = options.firstChapters != null + options.firstChapters?.let { + optionFirstChapters.title = resources.getString( + R.string.download_option_first_n_chapters, + resources.getQuantityString( + R.plurals.chapters, + it.chaptersCount, + it.chaptersCount, + ), + ) + optionFirstChapters.subtitle = it.branch + } + // Next N unread chapters + optionUnreadChapters.isVisible = options.unreadChapters != null + options.unreadChapters?.let { + optionUnreadChapters.title = if (it.chaptersCount == Int.MAX_VALUE) { + resources.getString(R.string.download_option_all_unread) + } else { + resources.getString( + R.string.download_option_next_unread_n_chapters, + resources.getQuantityString( + R.plurals.chapters, + it.chaptersCount, + it.chaptersCount, + ), + ) + } + } + } + } + + private fun onDownloadScheduled(isStarted: Boolean) { + val bundle = Bundle(1) + bundle.putBoolean(ARG_STARTED, isStarted) + setFragmentResult(RESULT_KEY, bundle) + dismiss() + } + + private fun showMoreOptions(isVisible: Boolean) = viewBinding?.apply { + cardFormat.isVisible = isVisible + textViewFormat.isVisible = isVisible + cardDestination.isVisible = isVisible + textViewDestination.isVisible = isVisible + } + + private fun setCheckedOption(id: Int) { + for (optionView in optionViews ?: return) { + optionView.isChecked = id == optionView.id + optionView.isButtonEnabled = optionView.isChecked + } + } + + private fun showBranchSelection(v: View) { + val option = viewModel.chaptersSelectOptions.value.wholeBranch ?: return + val branches = option.branches.keys.toList() + if (branches.size <= 1) { + return + } + val menu = PopupMenu(v.context, v) + for ((i, branch) in branches.withIndex()) { + menu.menu.add(Menu.NONE, Menu.NONE, i, branch ?: getString(R.string.unknown)) + } + menu.setOnMenuItemClickListener { + viewModel.setSelectedBranch(branches.getOrNull(it.order)) + true + } + menu.show() + } + + private fun showFirstChaptersCountSelection(v: View) { + val option = viewModel.chaptersSelectOptions.value.firstChapters ?: return + val menu = PopupMenu(v.context, v) + chaptersCount(option.maxAvailableCount).forEach { i -> + menu.menu.add(i.format()) + } + menu.setOnMenuItemClickListener { + viewModel.setFirstChaptersCount( + it.title?.toString()?.toIntOrNull() ?: return@setOnMenuItemClickListener false, + ) + true + } + menu.show() + } + + private fun showUnreadChaptersCountSelection(v: View) { + val option = viewModel.chaptersSelectOptions.value.unreadChapters ?: return + val menu = PopupMenu(v.context, v) + chaptersCount(option.maxAvailableCount).forEach { i -> + menu.menu.add(i.format()) + } + menu.menu.add(getString(R.string.chapters_all)) + menu.setOnMenuItemClickListener { + viewModel.setUnreadChaptersCount(it.title?.toString()?.toIntOrNull() ?: Int.MAX_VALUE) + true + } + menu.show() + } + + private fun chaptersCount(max: Int) = sequence { + yield(1) + var seed = 5 + var step = 5 + while (seed + step <= max) { + yield(seed) + step = when { + seed < 20 -> 5 + seed < 60 -> 10 + else -> 20 + } + seed += step + } + if (seed < max) { + yield(max) + } + } + + private class SnackbarResultListener(private val host: View) : FragmentResultListener { + + override fun onFragmentResult(requestKey: String, result: Bundle) { + val isStarted = result.getBoolean(ARG_STARTED, true) + val snackbar = Snackbar.make( + host, + if (isStarted) R.string.download_started else R.string.download_added, + Snackbar.LENGTH_LONG, + ) + (host.context.findActivity() as? BottomNavOwner)?.let { + snackbar.anchorView = it.bottomNav + } + snackbar.setAction(R.string.details) { + it.context.startActivity(Intent(it.context, DownloadsActivity::class.java)) + } + snackbar.show() + } + } + + companion object { + + private const val TAG = "DownloadDialogFragment" + private const val RESULT_KEY = "DOWNLOAD_STARTED" + private const val ARG_STARTED = "started" + private const val KEY_CHECKED_OPTION = "checked_opt" + const val ARG_MANGA = "manga" + + fun show(fm: FragmentManager, manga: Collection) = DownloadDialogFragment().withArgs(1) { + putParcelableArray(ARG_MANGA, manga.mapToArray { ParcelableManga(it) }) + }.showDistinct(fm, TAG) + + fun registerCallback(activity: FragmentActivity, snackbarHost: View) = + activity.supportFragmentManager.setFragmentResultListener( + RESULT_KEY, + activity, + SnackbarResultListener(snackbarHost), + ) + + fun registerCallback(fragment: Fragment, snackbarHost: View) = + fragment.childFragmentManager.setFragmentResultListener( + RESULT_KEY, + fragment.viewLifecycleOwner, + SnackbarResultListener(snackbarHost), + ) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt new file mode 100644 index 000000000..a385a0515 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt @@ -0,0 +1,242 @@ +package org.koitharu.kotatsu.download.ui.dialog + +import androidx.collection.ArrayMap +import androidx.collection.ArraySet +import androidx.collection.MutableLongLongMap +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +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 +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.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.core.util.ext.sizeOrZero +import org.koitharu.kotatsu.download.ui.worker.DownloadTask +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker +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.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, + private val mangaRepositoryFactory: MangaRepository.Factory, + private val historyRepository: HistoryRepository, + private val settings: AppSettings, +) : BaseViewModel() { + + val manga = savedStateHandle.require>(DownloadDialogFragment.ARG_MANGA).map { + it.manga + } + private val mangaDetails = SuspendLazy { + coroutineScope { + manga.map { m -> + async { m.getDetails() } + }.awaitAll() + } + } + val onScheduled = MutableEventFlow() + val defaultFormat = MutableStateFlow(null) + val availableDestinations = MutableStateFlow(listOf(defaultDestination())) + val chaptersSelectOptions = MutableStateFlow( + ChapterSelectOptions( + wholeManga = ChaptersSelectMacro.WholeManga(0), + wholeBranch = null, + firstChapters = null, + unreadChapters = null, + ), + ) + val isOptionsLoading = MutableStateFlow(true) + + init { + launchJob(Dispatchers.Default) { + defaultFormat.value = settings.preferredDownloadFormat + } + launchJob(Dispatchers.Default) { + try { + loadAvailableOptions() + } finally { + isOptionsLoading.value = false + } + } + loadAvailableDestinations() + } + + fun confirm( + startNow: Boolean, + chaptersMacro: ChaptersSelectMacro, + format: DownloadFormat?, + destination: DirectoryModel?, + allowMetered: Boolean, + ) { + launchLoadingJob(Dispatchers.Default) { + val tasks = mangaDetails.get().map { m -> + val chapters = checkNotNull(m.chapters) { "Manga \"${m.title}\" cannot be loaded" } + mangaDataRepository.storeManga(m) + DownloadTask( + mangaId = m.id, + isPaused = !startNow, + isSilent = false, + chaptersIds = chaptersMacro.getChaptersIds(m.id, chapters)?.toLongArray(), + destination = destination?.file, + format = format, + allowMeteredNetwork = allowMetered, + ) + } + scheduler.schedule(tasks) + onScheduled.call(startNow) + } + } + + fun setSelectedBranch(branch: String?) { + val snapshot = chaptersSelectOptions.value + chaptersSelectOptions.value = snapshot.copy( + wholeBranch = snapshot.wholeBranch?.copy(branch), + ) + } + + fun setFirstChaptersCount(count: Int) { + val snapshot = chaptersSelectOptions.value + chaptersSelectOptions.value = snapshot.copy( + firstChapters = snapshot.firstChapters?.copy(count), + ) + } + + fun setUnreadChaptersCount(count: Int) { + val snapshot = chaptersSelectOptions.value + chaptersSelectOptions.value = snapshot.copy( + unreadChapters = snapshot.unreadChapters?.copy(count), + ) + } + + private fun defaultDestination() = DirectoryModel( + title = null, + titleRes = R.string.system_default, + file = null, + isRemovable = false, + isChecked = true, + isAvailable = true, + ) + + private suspend fun loadAvailableOptions() { + val details = mangaDetails.get() + var totalChapters = 0 + val branches = ArrayMap() + var maxChapters = 0 + var maxUnreadChapters = 0 + val preferredBranches = ArraySet(details.size) + val currentChaptersIds = MutableLongLongMap(details.size) + + details.forEach { m -> + val history = historyRepository.getOne(m) + if (history != null) { + currentChaptersIds[m.id] = history.chapterId + val unreadChaptersCount = m.chapters?.dropWhile { it.id != history.chapterId }.sizeOrZero() + maxUnreadChapters = maxOf(maxUnreadChapters, unreadChaptersCount) + } else { + maxUnreadChapters = maxOf(maxUnreadChapters, m.chapters.sizeOrZero()) + } + maxChapters = maxOf(maxChapters, m.chapters.sizeOrZero()) + preferredBranches.add(m.getPreferredBranch(history)) + m.chapters?.forEach { c -> + totalChapters++ + branches.increment(c.branch) + } + } + val defaultBranch = preferredBranches.firstOrNull() + chaptersSelectOptions.value = ChapterSelectOptions( + wholeManga = ChaptersSelectMacro.WholeManga(totalChapters), + wholeBranch = if (branches.size > 1) { + ChaptersSelectMacro.WholeBranch( + branches = branches, + selectedBranch = defaultBranch, + ) + } else { + null + }, + firstChapters = if (maxChapters > 0) { + ChaptersSelectMacro.FirstChapters( + chaptersCount = minOf(5, maxChapters), + maxAvailableCount = maxChapters, + branch = defaultBranch, + ) + } else { + null + }, + unreadChapters = if (currentChaptersIds.isNotEmpty()) { + ChaptersSelectMacro.UnreadChapters( + chaptersCount = minOf(5, maxUnreadChapters), + maxAvailableCount = maxUnreadChapters, + currentChaptersIds = currentChaptersIds, + ) + } else { + null + }, + ) + } + + private fun loadAvailableDestinations() = launchJob(Dispatchers.Default) { + val defaultDir = manga.mapToSet { + localMangaRepository.getOutputDir(it, null) + }.singleOrNull() + val dirs = localStorageManager.getWriteableDirs() + availableDestinations.value = buildList(dirs.size + 1) { + if (defaultDir == null) { + add(defaultDestination()) + } else if (defaultDir !in dirs) { + add( + DirectoryModel( + title = localStorageManager.getDirectoryDisplayName(defaultDir, isFullPath = false), + titleRes = 0, + file = defaultDir, + isChecked = true, + isAvailable = true, + isRemovable = false, + ), + ) + } + dirs.mapTo(this) { dir -> + DirectoryModel( + title = localStorageManager.getDirectoryDisplayName(dir, isFullPath = false), + titleRes = 0, + file = dir, + isChecked = dir == defaultDir, + isAvailable = true, + isRemovable = false, + ) + } + } + } + + private suspend fun Manga.getDetails(): Manga = runCatchingCancellable { + mangaRepositoryFactory.create(source).getDetails(this) + }.onFailure { e -> + e.printStackTraceDebug() + }.getOrDefault(this) + + private fun MutableMap.increment(key: T) { + put(key, getOrDefault(key, 0) + 1) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt deleted file mode 100644 index ae9bf076a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.koitharu.kotatsu.download.ui.dialog - -import android.content.res.Resources -import androidx.annotation.DrawableRes -import org.koitharu.kotatsu.R -import java.util.Locale -import com.google.android.material.R as materialR - -sealed interface DownloadOption { - - val chaptersIds: Set - - @get:DrawableRes - val iconResId: Int - - val chaptersCount: Int - get() = chaptersIds.size - - fun getLabel(resources: Resources): CharSequence - - class AllChapters( - val branch: String, - override val chaptersIds: Set, - ) : DownloadOption { - - override val iconResId = R.drawable.ic_select_group - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString(R.string.download_option_all_chapters, branch) - } - } - - class WholeManga( - override val chaptersIds: Set, - ) : DownloadOption { - - override val iconResId = materialR.drawable.abc_ic_menu_selectall_mtrl_alpha - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString(R.string.download_option_whole_manga) - } - } - - class FirstChapters( - override val chaptersIds: Set, - ) : DownloadOption { - - override val iconResId = R.drawable.ic_list_start - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString( - R.string.download_option_first_n_chapters, - resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount) - .lowercase(Locale.getDefault()), - ) - } - } - - class AllUnreadChapters( - override val chaptersIds: Set, - val branch: String?, - ) : DownloadOption { - - override val iconResId = R.drawable.ic_list_end - - override fun getLabel(resources: Resources): CharSequence { - return if (branch == null) { - resources.getString(R.string.download_option_all_unread) - } else { - resources.getString(R.string.download_option_all_unread_b, branch) - } - } - } - - class NextUnreadChapters( - override val chaptersIds: Set, - ) : DownloadOption { - - override val iconResId = R.drawable.ic_list_next - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString( - R.string.download_option_next_unread_n_chapters, - resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount) - .lowercase(Locale.getDefault()), - ) - } - } - - class SelectionHint : DownloadOption { - - override val chaptersIds: Set = emptySet() - override val iconResId = R.drawable.ic_tap - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString(R.string.download_option_manual_selection) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt deleted file mode 100644 index 3a277787f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.download.ui.dialog - -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.databinding.ItemDownloadOptionBinding - -fun downloadOptionAD( - onClickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemDownloadOptionBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.root.setOnClickListener { v -> onClickListener.onItemClick(item, v) } - - bind { - with(binding.root) { - title = item.getLabel(resources) - subtitle = if (item.chaptersCount == 0) null else resources.getQuantityString( - R.plurals.chapters, - item.chaptersCount, - item.chaptersCount, - ) - setIconResource(item.iconResId) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt index 274dd0a4d..0b3175086 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt @@ -7,9 +7,11 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.work.WorkInfo -import coil.ImageLoader -import coil.request.SuccessResult -import coil.util.CoilUtils +import coil3.ImageLoader +import coil3.request.SuccessResult +import coil3.request.allowRgb565 +import coil3.request.transformations +import coil3.util.CoilUtils import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job @@ -19,8 +21,8 @@ import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter @@ -92,7 +94,7 @@ fun downloadItemAD( allowRgb565(true) transformations(TrimTransformation()) memoryCacheKey(item.coverCacheKey) - source(item.manga?.source) + mangaSourceExtra(item.manga?.source) enqueueWith(coil) } } @@ -124,6 +126,7 @@ fun downloadItemAD( binding.buttonCancel.isVisible = true binding.buttonResume.isVisible = false binding.buttonSkip.isVisible = false + binding.buttonSkipAll.isVisible = false binding.buttonPause.isVisible = false } @@ -147,6 +150,7 @@ fun downloadItemAD( binding.buttonResume.isVisible = item.isPaused binding.buttonResume.setText(if (item.error == null) R.string.resume else R.string.retry) binding.buttonSkip.isVisible = item.isPaused && item.error != null + binding.buttonSkipAll.isVisible = item.isPaused && item.error != null binding.buttonPause.isVisible = item.canPause } @@ -169,6 +173,7 @@ fun downloadItemAD( binding.buttonCancel.isVisible = false binding.buttonResume.isVisible = false binding.buttonSkip.isVisible = false + binding.buttonSkipAll.isVisible = false binding.buttonPause.isVisible = false } @@ -182,6 +187,7 @@ fun downloadItemAD( binding.buttonCancel.isVisible = false binding.buttonResume.isVisible = false binding.buttonSkip.isVisible = false + binding.buttonSkipAll.isVisible = false binding.buttonPause.isVisible = false } @@ -195,6 +201,7 @@ fun downloadItemAD( binding.buttonCancel.isVisible = false binding.buttonResume.isVisible = false binding.buttonSkip.isVisible = false + binding.buttonSkipAll.isVisible = false binding.buttonPause.isVisible = false } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt index e783abf92..44b5c7f2d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt @@ -7,7 +7,7 @@ import androidx.core.text.bold import androidx.core.text.buildSpannedString import androidx.core.text.color import androidx.work.WorkInfo -import coil.memory.MemoryCache +import coil3.memory.MemoryCache import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index 65cd369b9..0c823471e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -9,7 +9,7 @@ import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets import androidx.core.view.updatePadding -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt index ea30e09f7..46db5de9c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.download.ui.list import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt index a58e152f3..33db4f1c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -25,7 +25,6 @@ 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 @@ -299,7 +298,7 @@ class DownloadsViewModel @Inject constructor( } private fun observeChapters(manga: Manga, workId: UUID): StateFlow?> = flow { - val chapterIds = workScheduler.getInputChaptersIds(workId)?.toSet() + val chapterIds = workScheduler.getTask(workId)?.chaptersIds val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow suspend fun mapChapters(): List { @@ -327,6 +326,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() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index 03f23e5cf..016bb14f2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -12,9 +12,10 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.graphics.drawable.toBitmap import androidx.work.WorkManager -import coil.ImageLoader -import coil.request.ImageRequest -import coil.size.Scale +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.size.Scale import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -26,6 +27,7 @@ import org.koitharu.kotatsu.core.ErrorReporterReceiver import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.isReportable +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.domain.DownloadState @@ -213,13 +215,15 @@ class DownloadNotificationFactory @AssistedInject constructor( builder.setWhen(System.currentTimeMillis()) builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.errorMessage)) if (state.error.isReportable()) { - builder.addAction( - NotificationCompat.Action( - 0, - context.getString(R.string.report), - ErrorReporterReceiver.getPendingIntent(context, state.error), - ), - ) + ErrorReporterReceiver.getPendingIntent(context, state.error)?.let { reportIntent -> + builder.addAction( + NotificationCompat.Action( + 0, + context.getString(R.string.report), + reportIntent, + ), + ) + } } } @@ -277,7 +281,7 @@ class DownloadNotificationFactory @AssistedInject constructor( ImageRequest.Builder(context) .data(manga.coverUrl) .allowHardware(false) - .tag(manga.source) + .mangaSourceExtra(manga.source) .size(coverWidth, coverHeight) .scale(Scale.FILL) .build(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt index 9f2f2e3b2..4df33a736 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt @@ -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() + 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()) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadTask.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadTask.kt new file mode 100644 index 000000000..f74a67c6e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadTask.kt @@ -0,0 +1,78 @@ +package org.koitharu.kotatsu.download.ui.worker + +import android.os.Parcelable +import androidx.work.Data +import kotlinx.parcelize.Parcelize +import org.koitharu.kotatsu.core.prefs.DownloadFormat +import org.koitharu.kotatsu.parsers.util.find +import java.io.File + +@Parcelize +class DownloadTask( + val mangaId: Long, + val isPaused: Boolean, + val isSilent: Boolean, + val chaptersIds: LongArray?, + val destination: File?, + val format: DownloadFormat?, + val allowMeteredNetwork: Boolean, +) : Parcelable { + + constructor(data: Data) : this( + mangaId = data.getLong(MANGA_ID, 0L), + isPaused = data.getBoolean(START_PAUSED, false), + isSilent = data.getBoolean(IS_SILENT, false), + chaptersIds = data.getLongArray(CHAPTERS)?.takeUnless(LongArray::isEmpty), + destination = data.getString(DESTINATION)?.let { File(it) }, + format = data.getString(FORMAT)?.let { DownloadFormat.entries.find(it) }, + allowMeteredNetwork = data.getBoolean(ALLOW_METERED, true), + ) + + fun toData(): Data = Data.Builder() + .putLong(MANGA_ID, mangaId) + .putBoolean(START_PAUSED, isPaused) + .putBoolean(IS_SILENT, isSilent) + .putLongArray(CHAPTERS, chaptersIds ?: LongArray(0)) + .putString(DESTINATION, destination?.path) + .putString(FORMAT, format?.name) + .build() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DownloadTask + + if (mangaId != other.mangaId) return false + if (isPaused != other.isPaused) return false + if (isSilent != other.isSilent) return false + if (!(chaptersIds contentEquals other.chaptersIds)) return false + if (destination != other.destination) return false + if (format != other.format) return false + if (allowMeteredNetwork != other.allowMeteredNetwork) return false + + return true + } + + override fun hashCode(): Int { + var result = mangaId.hashCode() + result = 31 * result + isPaused.hashCode() + result = 31 * result + isSilent.hashCode() + result = 31 * result + (chaptersIds?.contentHashCode() ?: 0) + result = 31 * result + (destination?.hashCode() ?: 0) + result = 31 * result + (format?.hashCode() ?: 0) + result = 31 * result + allowMeteredNetwork.hashCode() + return result + } + + private companion object { + + const val MANGA_ID = "manga_id" + const val IS_SILENT = "silent" + const val START_PAUSED = "paused" + const val CHAPTERS = "chapters" + const val DESTINATION = "dest" + const val FORMAT = "format" + const val ALLOW_METERED = "metered" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index c62b7f807..ccb79fea3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -71,8 +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.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.domain.MangaLock import org.koitharu.kotatsu.local.domain.model.LocalManga @@ -81,6 +80,7 @@ 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.mapToSet +import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.domain.PageLoader import java.io.File @@ -101,16 +101,14 @@ class DownloadWorker @AssistedInject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, private val settings: AppSettings, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, + private val slowdownDispatcher: DownloadSlowdownDispatcher, private val imageProxyInterceptor: ImageProxyInterceptor, notificationFactoryFactory: DownloadNotificationFactory.Factory, ) : CoroutineWorker(appContext, params) { - private val notificationFactory = notificationFactoryFactory.create( - uuid = params.id, - isSilent = params.inputData.getBoolean(IS_SILENT, false), - ) + 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 @@ -122,14 +120,16 @@ class DownloadWorker @AssistedInject constructor( override suspend fun doWork(): Result { setForeground(getForegroundInfo()) - val mangaId = inputData.getLong(MANGA_ID, 0L) - val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() + val manga = mangaDataRepository.findMangaById(task.mangaId) ?: return Result.failure() publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it }) - val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } val downloadedIds = getDoneChapters(manga) return try { - withContext(PausingHandle()) { - downloadMangaImpl(manga, chaptersIds, downloadedIds) + val pausingHandle = PausingHandle() + if (task.isPaused) { + pausingHandle.pause() + } + withContext(pausingHandle) { + downloadMangaImpl(manga, task, downloadedIds) } Result.success(currentState.toWorkData()) } catch (e: CancellationException) { @@ -170,7 +170,7 @@ class DownloadWorker @AssistedInject constructor( private suspend fun downloadMangaImpl( subject: Manga, - includedIds: LongArray?, + task: DownloadTask, excludedIds: Set, ) { var manga = subject @@ -183,7 +183,7 @@ class DownloadWorker @AssistedInject constructor( PausingReceiver.createIntentFilter(id), ContextCompat.RECEIVER_NOT_EXPORTED, ) - val destination = localMangaRepository.getOutputDir(manga) + val destination = localMangaRepository.getOutputDir(manga, task.destination) checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } var output: LocalMangaOutput? = null try { @@ -193,7 +193,11 @@ class DownloadWorker @AssistedInject constructor( } val repo = mangaRepositoryFactory.create(manga.source) val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = LocalMangaOutput.getOrCreate(destination, mangaDetails, settings.preferredDownloadFormat) + output = LocalMangaOutput.getOrCreate( + root = destination, + manga = mangaDetails, + format = task.format ?: settings.preferredDownloadFormat, + ) val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } if (coverUrl.isNotEmpty()) { downloadFile(coverUrl, destination, repo.source).let { file -> @@ -201,7 +205,7 @@ class DownloadWorker @AssistedInject constructor( file.deleteAwait() } } - val chapters = getChapters(mangaDetails, includedIds) + val chapters = getChapters(mangaDetails, task) for ((chapterIndex, chapter) in chapters.withIndex()) { checkIsPaused() if (chaptersToSkip.remove(chapter.value.id)) { @@ -258,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)) @@ -266,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) { @@ -307,6 +311,10 @@ class DownloadWorker @AssistedInject constructor( DOWNLOAD_ERROR_DELAY } if (countDown <= 0 || retryDelay < 0 || retryDelay > MAX_RETRY_DELAY) { + val pausingHandle = PausingHandle.current() + if (pausingHandle.skipAllErrors()) { + return null + } publishState( currentState.copy( isPaused = true, @@ -317,7 +325,6 @@ class DownloadWorker @AssistedInject constructor( ), ) countDown = MAX_FAILSAFE_ATTEMPTS - val pausingHandle = PausingHandle.current() pausingHandle.pause() try { pausingHandle.awaitResumed() @@ -359,7 +366,7 @@ class DownloadWorker @AssistedInject constructor( .use { response -> val file = File(destination, UUID.randomUUID().toString() + ".tmp") try { - checkNotNull(response.body).use { body -> + response.requireBody().use { body -> file.sink(append = false).buffer().use { it.writeAllCancellable(body.source()) } @@ -400,10 +407,10 @@ class DownloadWorker @AssistedInject constructor( private fun getChapters( manga: Manga, - includedIds: LongArray?, + task: DownloadTask, ): List> { val chapters = checkNotNull(manga.chapters) { "Chapters list must not be null" } - val chaptersIdsSet = includedIds?.toMutableSet() + val chaptersIdsSet = task.chaptersIds?.toMutableSet() val result = ArrayList>((chaptersIdsSet ?: chapters).size) val counters = HashMap() for (chapter in chapters) { @@ -416,7 +423,7 @@ class DownloadWorker @AssistedInject constructor( } if (chaptersIdsSet != null) { check(chaptersIdsSet.isEmpty()) { - "${chaptersIdsSet.size} of ${includedIds.size} requested chapters not found in manga" + "${chaptersIdsSet.size} of ${task.chaptersIds.size} requested chapters not found in manga" } } check(result.isNotEmpty()) { "Chapters list must not be empty" } @@ -427,31 +434,8 @@ class DownloadWorker @AssistedInject constructor( class Scheduler @Inject constructor( @ApplicationContext private val context: Context, private val workManager: WorkManager, - private val dataRepository: MangaDataRepository, - private val settings: AppSettings, ) { - suspend fun schedule(manga: Manga, chaptersIds: Collection?, isSilent: Boolean) { - dataRepository.storeManga(manga) - val data = Data.Builder() - .putLong(MANGA_ID, manga.id) - .putBoolean(IS_SILENT, isSilent) - if (!chaptersIds.isNullOrEmpty()) { - data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray()) - } - scheduleImpl(listOf(data.build())) - } - - suspend fun schedule(manga: Collection) { - val data = manga.map { - dataRepository.storeManga(it) - Data.Builder() - .putLong(MANGA_ID, it.id) - .build() - } - scheduleImpl(data) - } - fun observeWorks(): Flow> = workManager .getWorkInfosByTagFlow(TAG) @@ -464,8 +448,8 @@ class DownloadWorker @AssistedInject constructor( .build() } - suspend fun getInputChaptersIds(workId: UUID): LongArray? { - return workManager.getWorkInputData(workId)?.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } + suspend fun getTask(workId: UUID): DownloadTask? { + return workManager.getWorkInputData(workId)?.let { DownloadTask(it) } } suspend fun cancel(id: UUID) { @@ -507,8 +491,8 @@ class DownloadWorker @AssistedInject constructor( workManager.deleteWorks(finishedWorks.mapToSet { it.id }) } - suspend fun updateConstraints() { - val constraints = createConstraints() + suspend fun updateConstraints(allowMeteredNetwork: Boolean) { + val constraints = createConstraints(allowMeteredNetwork) val works = workManager.awaitWorkInfosByTag(TAG) for (work in works) { if (work.state.isFinished) { @@ -523,26 +507,25 @@ class DownloadWorker @AssistedInject constructor( } } - private suspend fun scheduleImpl(data: Collection) { - if (data.isEmpty()) { + suspend fun schedule(tasks: Collection) { + if (tasks.isEmpty()) { return } - val constraints = createConstraints() - val requests = data.map { inputData -> + val requests = tasks.map { task -> OneTimeWorkRequestBuilder() - .setConstraints(constraints) + .setConstraints(createConstraints(task.allowMeteredNetwork)) .addTag(TAG) .keepResultsForAtLeast(30, TimeUnit.DAYS) .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS) - .setInputData(inputData) + .setInputData(task.toData()) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() } workManager.enqueue(requests).await() } - private fun createConstraints() = Constraints.Builder() - .setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + private fun createConstraints(allowMeteredNetwork: Boolean) = Constraints.Builder() + .setRequiredNetworkType(if (allowMeteredNetwork) NetworkType.CONNECTED else NetworkType.UNMETERED) .build() } @@ -552,10 +535,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 MANGA_ID = "manga_id" - const val CHAPTERS_IDS = "chapters" - const val IS_SILENT = "silent" const val TAG = "download" } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt index e02205230..3eb184121 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt @@ -53,7 +53,9 @@ class PausingHandle : AbstractCoroutineContextElement(PausingHandle) { } } - fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = skipAllErrors) + fun skipAllErrors(): Boolean = skipAllErrors + + fun skipCurrentError(): Boolean = skipError.compareAndSet(expect = true, update = false) companion object : CoroutineContext.Key { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt index 4ccdb344f..04249df34 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart @@ -338,6 +339,7 @@ class MangaSourcesRepository @Inject constructor( }.map { getExternalSources() }.distinctUntilChanged() + .conflate() } private fun getExternalSources() = context.packageManager.queryIntentContentProviders( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 8313db521..de68d0922 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -18,7 +18,7 @@ import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity @@ -26,7 +26,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog +import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner @@ -250,7 +250,7 @@ class ExploreFragment : val listener = DialogInterface.OnClickListener { _, which -> viewModel.respondSuggestionTip(which == DialogInterface.BUTTON_POSITIVE) } - TwoButtonsAlertDialog.Builder(requireContext()) + BigButtonsAlertDialog.Builder(requireContext()) .setIcon(R.drawable.ic_suggestion) .setTitle(R.string.suggestions_enable_prompt) .setPositiveButton(R.string.enable, listener) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt index f275ff345..f8268614d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.explore.ui.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt index 72cb24a6e..3f51907ca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt @@ -3,7 +3,12 @@ package org.koitharu.kotatsu.explore.ui.adapter import android.view.View import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder +import coil3.request.transformations import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary @@ -18,10 +23,10 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.setProgressIcon -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding @@ -94,7 +99,7 @@ fun recommendationMangaItemAD( defaultPlaceholders(context) allowRgb565(true) transformations(TrimTransformation()) - source(item.manga.source) + mangaSourceExtra(item.manga.source) enqueueWith(coil) } } @@ -128,7 +133,7 @@ fun exploreSourceListItemAD( fallback(fallbackIcon) placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)) error(fallbackIcon) - source(item.source) + mangaSourceExtra(item.source) enqueueWith(coil) } } @@ -160,7 +165,7 @@ fun exploreSourceGridItemAD( fallback(fallbackIcon) placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name)) error(fallbackIcon) - source(item.source) + mangaSourceExtra(item.source) enqueueWith(coil) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt index f5129ba9a..b9073cf47 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt @@ -10,7 +10,7 @@ import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt index 8569744cd..67a5484f6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.ReorderableListAdapter import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt index 643aec708..ea4e032dd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt @@ -14,14 +14,19 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.ImageViewCompat import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 +import coil3.request.crossfade +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding import org.koitharu.kotatsu.databinding.ItemCategoryBinding import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener @@ -87,7 +92,7 @@ fun categoryAD( coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run { placeholder(R.drawable.ic_placeholder) fallback(fallback) - source(cover?.mangaSource) + mangaSourceExtra(cover?.mangaSource) crossfade(crossFadeDuration * (i + 1)) error(R.drawable.ic_error_placeholder) allowRgb565(true) @@ -153,7 +158,7 @@ fun allCategoriesAD( coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run { placeholder(R.drawable.ic_placeholder) fallback(fallback) - source(cover?.mangaSource) + mangaSourceExtra(cover?.mangaSource) crossfade(crossFadeDuration * (i + 1)) error(R.drawable.ic_error_placeholder) allowRgb565(true) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt index a05651ab8..df99011ab 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt @@ -7,7 +7,7 @@ import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt index 28039b358..6f8fc332b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt @@ -9,7 +9,12 @@ import androidx.core.graphics.ColorUtils import androidx.core.view.isVisible import androidx.core.widget.ImageViewCompat import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 +import coil3.request.crossfade +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.disposeImageRequest @@ -17,8 +22,8 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getAnimationDuration import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemCategoriesHeaderBinding import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity @@ -74,7 +79,7 @@ fun categoriesHeaderAD( view.newImageRequest(lifecycleOwner, cover.url)?.run { placeholder(R.drawable.ic_placeholder) fallback(fallback) - source(cover.mangaSource) + mangaSourceExtra(cover.mangaSource) crossfade(crossFadeDuration * (i + 1)) error(R.drawable.ic_error_placeholder) allowRgb565(true) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt index dcdc92844..f820d6994 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt index bed90f277..217a4edb9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt @@ -12,7 +12,7 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.viewModels -import coil.ImageLoader +import coil3.ImageLoader import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 110bf6373..ec95fea68 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -332,6 +332,15 @@ class FilterCoordinator @Inject constructor( } } + fun toggleDemographic(value: Demographic, isSelected: Boolean) { + currentListFilter.update { oldValue -> + oldValue.copy( + demographics = if (isSelected) oldValue.demographics + value else oldValue.demographics - value, + query = oldValue.takeQueryIfSupported(), + ) + } + } + fun toggleContentType(value: ContentType, isSelected: Boolean) { currentListFilter.update { oldValue -> oldValue.copy( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt index 90ca05cd8..b5b1f83c0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt @@ -8,6 +8,7 @@ import android.view.View import android.widget.RelativeLayout import android.widget.TextView import androidx.annotation.AttrRes +import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.view.isInvisible @@ -68,6 +69,10 @@ class FilterFieldLayout @JvmOverloads constructor( } } + fun setTitle(@StringRes titleResId: Int) { + binding.textViewTitle.setText(titleResId) + } + fun setError(errorMessage: String?) { if (errorMessage == null && errorView == null) { return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index 0dc0271e7..ff7d30917 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -16,7 +16,13 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet +import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Demographic +import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.YEAR_UNKNOWN +import java.util.Locale import javax.inject.Inject @AndroidEntryPoint @@ -55,6 +61,13 @@ class FilterHeaderFragment : BaseFragment(), ChipsV override fun onChipCloseClick(chip: Chip, data: Any?) { when (data) { is String -> filter.setQuery(null) + is ContentRating -> filter.toggleContentRating(data, false) + is Demographic -> filter.toggleDemographic(data, false) + is ContentType -> filter.toggleContentType(data, false) + is MangaState -> filter.toggleState(data, false) + is Locale -> filter.setLocale(null) + is Int -> filter.setYear(YEAR_UNKNOWN) + is IntRange -> filter.setYearRange(YEAR_UNKNOWN, YEAR_UNKNOWN) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt index c29c0eeec..e011beaa7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt @@ -3,12 +3,15 @@ package org.koitharu.kotatsu.filter.ui import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.filter.ui.model.FilterProperty +import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.search.domain.MangaSearchRepository import javax.inject.Inject import com.google.android.material.R as materialR @@ -18,15 +21,14 @@ class FilterHeaderProducer @Inject constructor( ) { fun observeHeader(filterCoordinator: FilterCoordinator): Flow { - return combine(filterCoordinator.tags, filterCoordinator.query) { tags, query -> - createChipsList( + return combine(filterCoordinator.tags, filterCoordinator.observe()) { tags, snapshot -> + val chipList = createChipsList( source = filterCoordinator.mangaSource, capabilities = filterCoordinator.capabilities, - property = tags, - query = query, + tagsProperty = tags, + snapshot = snapshot.listFilter, limit = 8, ) - }.combine(filterCoordinator.observe()) { chipList, snapshot -> FilterHeaderModel( chips = chipList, sortOrder = snapshot.sortOrder, @@ -38,20 +40,20 @@ class FilterHeaderProducer @Inject constructor( private suspend fun createChipsList( source: MangaSource, capabilities: MangaListFilterCapabilities, - property: FilterProperty, - query: String?, + tagsProperty: FilterProperty, + snapshot: MangaListFilter, limit: Int, ): List { val result = ArrayDeque(limit + 3) - if (query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) { - val selectedTags = property.selectedItems.toMutableSet() + if (snapshot.query.isNullOrEmpty() || capabilities.isSearchWithFiltersSupported) { + val selectedTags = tagsProperty.selectedItems.toMutableSet() var tags = if (selectedTags.isEmpty()) { searchRepository.getTagsSuggestion("", limit, source) } else { searchRepository.getTagsSuggestion(selectedTags).take(limit) } if (tags.size < limit) { - tags = tags + property.availableItems.take(limit - tags.size) + tags = tags + tagsProperty.availableItems.take(limit - tags.size) } if (tags.isEmpty() && selectedTags.isEmpty()) { return emptyList() @@ -77,13 +79,59 @@ class FilterHeaderProducer @Inject constructor( result.addFirst(model) } } - if (!query.isNullOrEmpty()) { + snapshot.locale?.let { result.addFirst( ChipsView.ChipModel( - title = query, + title = it.getDisplayName(it).toTitleCase(it), + icon = R.drawable.ic_language, + isCloseable = true, + data = it, + ), + ) + } + snapshot.types.forEach { + result.addFirst( + ChipsView.ChipModel( + titleResId = it.titleResId, + isCloseable = true, + data = it, + ), + ) + } + snapshot.demographics.forEach { + result.addFirst( + ChipsView.ChipModel( + titleResId = it.titleResId, + isCloseable = true, + data = it, + ), + ) + } + snapshot.contentRating.forEach { + result.addFirst( + ChipsView.ChipModel( + titleResId = it.titleResId, + isCloseable = true, + data = it, + ), + ) + } + snapshot.states.forEach { + result.addFirst( + ChipsView.ChipModel( + titleResId = it.titleResId, + isCloseable = true, + data = it, + ), + ) + } + if (!snapshot.query.isNullOrEmpty()) { + result.addFirst( + ChipsView.ChipModel( + title = snapshot.query, icon = materialR.drawable.abc_ic_search_api_material, isCloseable = true, - data = query, + data = snapshot.query, ), ) } @@ -97,6 +145,5 @@ class FilterHeaderProducer @Inject constructor( private fun moreTagsChip() = ChipsView.ChipModel( titleResId = R.string.more, isDropdown = true, - // icon = materialR.drawable.abc_ic_menu_overflow_material, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt index dc9115332..53952a129 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -68,12 +68,20 @@ class FilterSheetFragment : BaseAdaptiveSheet(), filter.year.observe(viewLifecycleOwner, this::onYearChanged) filter.yearRange.observe(viewLifecycleOwner, this::onYearRangeChanged) + binding.layoutGenres.setTitle( + if (filter.capabilities.isMultipleTagsSupported) { + R.string.genres + } else { + R.string.genre + }, + ) binding.spinnerLocale.onItemSelectedListener = this binding.spinnerOriginalLocale.onItemSelectedListener = this binding.spinnerOrder.onItemSelectedListener = this binding.chipsState.onChipClickListener = this binding.chipsTypes.onChipClickListener = this binding.chipsContentRating.onChipClickListener = this + binding.chipsDemographics.onChipClickListener = this binding.chipsGenres.onChipClickListener = this binding.chipsGenresExclude.onChipClickListener = this binding.sliderYear.addOnChangeListener(this::onSliderValueChange) @@ -143,6 +151,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), is ContentType -> filter.toggleContentType(data, !chip.isChecked) is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) + is Demographic -> filter.toggleDemographic(data, !chip.isChecked) null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt index 5b83142b1..1cc380bd4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt @@ -10,22 +10,27 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus +import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorState +import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaTag @HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class) class TagsCatalogViewModel @AssistedInject constructor( @Assisted private val filter: FilterCoordinator, @Assisted private val isExcluded: Boolean, + private val mangaDataRepository: MangaDataRepository, ) : BaseViewModel() { val searchQuery = MutableStateFlow("") @@ -33,23 +38,13 @@ class TagsCatalogViewModel @AssistedInject constructor( private val filterProperty: StateFlow> get() = if (isExcluded) filter.tagsExcluded else filter.tags + @Suppress("RemoveExplicitTypeArguments") private val tags: StateFlow> = combine( filter.getAllTags(), + flow> { emit(emptyList()); emit(mangaDataRepository.findTags(filter.mangaSource)) }, filterProperty.map { it.selectedItems }, - ) { all, selected -> - all.fold( - onSuccess = { - it.map { tag -> - TagCatalogItem( - tag = tag, - isChecked = tag in selected, - ) - } - }, - onFailure = { - listOf(it.toErrorState(false)) - }, - ) + ) { available, cached, selected -> + buildList(available, cached, selected) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) val content = combine(tags, searchQuery) { raw, query -> @@ -66,6 +61,50 @@ class TagsCatalogViewModel @AssistedInject constructor( } } + private fun buildList( + available: Result>, + cached: Collection, + selected: Set, + ): List { + val capacity = (available.getOrNull()?.size ?: 1) + cached.size + val result = ArrayList(capacity) + val added = HashSet(capacity) + available.getOrNull()?.forEach { tag -> + if (added.add(tag.title)) { + result.add( + TagCatalogItem( + tag = tag, + isChecked = tag in selected, + ), + ) + } + } + cached.forEach { tag -> + if (added.add(tag.title)) { + result.add( + TagCatalogItem( + tag = tag, + isChecked = tag in selected, + ), + ) + } + } + if (result.isNotEmpty()) { + val locale = (filter.mangaSource as? MangaParserSource)?.locale + result.sortWith(compareBy(TagTitleComparator(locale)) { (it as TagCatalogItem).tag }) + } + available.exceptionOrNull()?.let { error -> + result.add( + if (result.isEmpty()) { + error.toErrorState(canRetry = false) + } else { + error.toErrorFooter() + }, + ) + } + return result + } + @AssistedFactory interface Factory { fun create(filter: FilterCoordinator, isExcludeTag: Boolean): TagsCatalogViewModel diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt index 426d5754e..64fe0b93e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.history.ui import android.content.Context import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListListener diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt index a8e7440e1..20f09ba8d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt @@ -13,12 +13,15 @@ import androidx.core.graphics.drawable.toBitmap import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.swiperefreshlayout.widget.CircularProgressDrawable -import coil.ImageLoader -import coil.request.CachePolicy -import coil.request.ErrorResult -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.target.ViewTarget +import coil3.Image +import coil3.ImageLoader +import coil3.asDrawable +import coil3.request.CachePolicy +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.lifecycle +import coil3.target.ViewTarget import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.google.android.material.snackbar.Snackbar @@ -33,9 +36,9 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ActivityImageBinding import org.koitharu.kotatsu.databinding.ItemErrorStateBinding import org.koitharu.kotatsu.parsers.model.MangaSource @@ -120,7 +123,7 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene .memoryCachePolicy(CachePolicy.DISABLED) .lifecycle(this) .listener(this) - .source(MangaSource(intent.getStringExtra(EXTRA_SOURCE))) + .mangaSourceExtra(MangaSource(intent.getStringExtra(EXTRA_SOURCE))) .target(SsivTarget(viewBinding.ssiv)) .enqueueWith(coil) } @@ -152,9 +155,9 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene override val view: SubsamplingScaleImageView, ) : ViewTarget { - override fun onError(error: Drawable?) = setDrawable(error) + override fun onError(error: Image?) = setDrawable(error?.asDrawable(view.resources)) - override fun onSuccess(result: Drawable) = setDrawable(result) + override fun onSuccess(result: Image) = setDrawable(result.asDrawable(view.resources)) override fun equals(other: Any?): Boolean { return (this === other) || (other is SsivTarget && view == other.view) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageMenuProvider.kt index 73ddf3a93..e190c9def 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageMenuProvider.kt @@ -11,8 +11,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.MenuProvider import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.tryLaunch -import org.koitharu.kotatsu.local.data.isZipUri class ImageMenuProvider( private val activity: ComponentActivity, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt index 167f635aa..8839a6416 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt @@ -5,20 +5,21 @@ import android.graphics.Bitmap import android.net.Uri import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.SavedStateHandle -import coil.ImageLoader -import coil.request.CachePolicy -import coil.request.ImageRequest +import coil3.ImageLoader +import coil3.request.CachePolicy +import coil3.request.ImageRequest import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.ui.BaseActivity 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.getDrawableOrThrow +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.core.util.ext.source import javax.inject.Inject @HiltViewModel @@ -36,7 +37,7 @@ class ImageViewModel @Inject constructor( .memoryCachePolicy(CachePolicy.READ_ONLY) .data(savedStateHandle.require(BaseActivity.EXTRA_DATA)) .memoryCachePolicy(CachePolicy.DISABLED) - .source(savedStateHandle[ImageActivity.EXTRA_SOURCE]) + .mangaSourceExtra(MangaSource(savedStateHandle[ImageActivity.EXTRA_SOURCE])) .build() val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap() runInterruptible(Dispatchers.IO) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ReadingProgress.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ReadingProgress.kt index e2c638512..4ce030bf0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ReadingProgress.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ReadingProgress.kt @@ -38,7 +38,7 @@ data class ReadingProgress( companion object { const val PROGRESS_NONE = -1f - const val PROGRESS_COMPLETED = 0.995f + const val PROGRESS_COMPLETED = 1f fun isValid(percent: Float) = percent in 0f..1f diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 4efd19b52..0cf0a0bc9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -16,7 +16,7 @@ import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.GridLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R @@ -45,7 +45,7 @@ import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver +import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.QuickFilterListener @@ -125,6 +125,7 @@ abstract class MangaListFragment : isEnabled = isSwipeRefreshEnabled } addMenuProvider(MangaListMenuProvider(this)) + DownloadDialogFragment.registerCallback(this, binding.recyclerView) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) @@ -132,7 +133,6 @@ abstract class MangaListFragment : viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) - viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView)) } override fun onDestroyView() { @@ -238,6 +238,7 @@ abstract class MangaListFragment : } override fun onFilterOptionClick(option: ListFilterOption) { + selectionController?.clear() (viewModel as? QuickFilterListener)?.toggleFilterOption(option) } @@ -322,7 +323,7 @@ abstract class MangaListFragment : } R.id.action_save -> { - viewModel.download(selectedItems) + DownloadDialogFragment.show(childFragmentManager, selectedItems) mode?.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index 5278dbf56..372edaba8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -17,12 +17,10 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag abstract class MangaListViewModel( private val settings: AppSettings, @@ -38,7 +36,6 @@ abstract class MangaListViewModel( key = AppSettings.KEY_GRID_SIZE, valueProducer = { gridSize / 100f }, ) - val onDownloadStarted = MutableEventFlow() val isIncognitoModeEnabled: Boolean get() = settings.isIncognitoModeEnabled @@ -47,13 +44,6 @@ abstract class MangaListViewModel( abstract fun onRetry() - fun download(items: Set) { - launchJob(Dispatchers.Default) { - downloadScheduler.schedule(items) - onDownloadStarted.call(Unit) - } - } - protected fun List.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) { filterNot { it.isNsfw } } else { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt index 3aef252ef..96905bd07 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt index 25e0f57f9..76fa9f0d7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt index 97fa7b00e..395b294f1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt @@ -2,7 +2,9 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 +import coil3.request.transformations import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver @@ -11,8 +13,8 @@ import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED import org.koitharu.kotatsu.list.ui.model.ListModel @@ -42,8 +44,7 @@ fun mangaGridItemAD( defaultPlaceholders(context) transformations(TrimTransformation()) allowRgb565(true) - tag(item.manga) - source(item.source) + mangaExtra(item.manga) enqueueWith(coil) } badge = itemView.bindBadge(badge, item.counter) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index dd0a5767e..d683521e4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt index 272235c05..f56a8916f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt @@ -1,7 +1,9 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 +import coil3.request.transformations import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver @@ -9,8 +11,8 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.list.ui.ListModelDiffCallback @@ -40,8 +42,7 @@ fun mangaListDetailedItemAD( defaultPlaceholders(context) transformations(TrimTransformation()) allowRgb565(true) - tag(item.manga) - source(item.source) + mangaExtra(item.manga) enqueueWith(coil) } binding.textViewTags.text = item.tags.joinToString(separator = ", ") { it.title ?: "" } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt index 14bf7f70b..a11da7f82 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt @@ -1,7 +1,9 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 +import coil3.request.transformations import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.image.TrimTransformation @@ -9,8 +11,8 @@ import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.list.ui.model.ListModel @@ -35,8 +37,7 @@ fun mangaListItemAD( defaultPlaceholders(context) allowRgb565(true) transformations(TrimTransformation()) - tag(item.manga) - source(item.source) + mangaExtra(item.manga) enqueueWith(coil) } badge = itemView.bindBadge(badge, item.counter) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt index 55e414919..119ed411a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.list.ui.model -import androidx.annotation.DrawableRes - data class ErrorFooter( val exception: Throwable, ) : ListModel { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt index 132f69382..549544996 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt @@ -9,10 +9,15 @@ import androidx.core.graphics.Insets import androidx.core.text.method.LinkMovementMethodCompat import androidx.core.view.isVisible import androidx.fragment.app.viewModels -import coil.ImageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.util.CoilUtils +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.error +import coil3.request.fallback +import coil3.request.lifecycle +import coil3.request.placeholder +import coil3.request.target +import coil3.util.CoilUtils import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R @@ -21,8 +26,10 @@ import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders +import org.koitharu.kotatsu.core.util.ext.drawable import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.textAndVisible @@ -169,7 +176,7 @@ class PreviewFragment : BaseFragment(), View.OnClickList .target(requireViewBinding().imageViewCover) .size(CoverSizeResolver(requireViewBinding().imageViewCover)) .data(imageUrl) - .tag(manga.source) + .mangaSourceExtra(manga.source) .crossfade(requireContext()) .lifecycle(viewLifecycleOwner) .placeholderMemoryCacheKey(manga.coverUrl) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFetcher.kt deleted file mode 100644 index 0ce579bd3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFetcher.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import android.net.Uri -import android.webkit.MimeTypeMap -import coil.ImageLoader -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.request.Options -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import okio.buffer -import okio.source -import org.koitharu.kotatsu.core.util.ext.getInputStreamOrClose -import org.koitharu.kotatsu.local.data.util.withExtraCloseable -import java.util.zip.ZipFile - -class CbzFetcher( - private val uri: Uri, - private val options: Options -) : Fetcher { - - override suspend fun fetch() = runInterruptible(Dispatchers.IO) { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name) - val bufferedSource = zip.getInputStreamOrClose(entry).source().withExtraCloseable(zip).buffer() - SourceResult( - source = ImageSource( - source = bufferedSource, - context = options.context, - metadata = CbzMetadata(uri), - ), - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext), - dataSource = DataSource.DISK, - ) - } - - class Factory : Fetcher.Factory { - - override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { - return if (data.scheme == "cbz") { - CbzFetcher(data, options) - } else { - null - } - } - } - - class CbzMetadata(val uri: Uri) : ImageSource.Metadata() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt index 3539e8ca7..3f1080d89 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt @@ -1,22 +1,15 @@ package org.koitharu.kotatsu.local.data -import android.net.Uri -import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP 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) - -fun Uri.isZipUri() = scheme.let { - it == URI_SCHEME_ZIP || it == "cbz" || it == "zip" -} - -fun Uri.isFileUri() = scheme == "file" +val File.isZipArchive: Boolean + get() = isFile && isZipExtension(extension) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index 9b99de67a..497967246 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -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 @@ -15,12 +16,11 @@ import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.AlphanumComparator -import org.koitharu.kotatsu.core.util.ext.children import org.koitharu.kotatsu.core.util.ext.deleteAwait -import org.koitharu.kotatsu.core.util.ext.filterWith 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 @@ -77,7 +77,9 @@ class LocalMangaRepository @Inject constructor( } override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = localMangaIndex.getAvailableTags().mapToSet { MangaTag(title = it, key = it, source = source) }, + availableTags = localMangaIndex.getAvailableTags( + skipNsfw = settings.isNsfwContentDisabled, + ).mapToSet { MangaTag(title = it, key = it, source = source) }, availableContentRating = if (!settings.isNsfwContentDisabled) { EnumSet.of(ContentRating.SAFE, ContentRating.ADULT) } else { @@ -124,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 { - return LocalMangaInput.of(chapter).getPages(chapter) + return LocalMangaParser(chapter.url.toUri()).getPages(chapter) } suspend fun delete(manga: Manga): Boolean { @@ -146,7 +148,7 @@ class LocalMangaRepository @Inject constructor( } suspend fun deleteChapters(manga: Manga, ids: Set) = 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) @@ -155,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) { @@ -186,7 +188,7 @@ class LocalMangaRepository @Inject constructor( } } } - }.firstOrNull()?.getManga() + }.firstOrNull()?.getManga(withDetails) }.onSuccess { x: LocalManga? -> if (x != null) { localMangaIndex.put(x) @@ -199,8 +201,8 @@ class LocalMangaRepository @Inject constructor( override suspend fun getRelated(seed: Manga): List = emptyList() - suspend fun getOutputDir(manga: Manga): File? { - val defaultDir = storageManager.getDefaultWriteableDir() + suspend fun getOutputDir(manga: Manga, fallback: File?): File? { + val defaultDir = fallback ?: storageManager.getDefaultWriteableDir() if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) { return defaultDir } @@ -216,10 +218,15 @@ class LocalMangaRepository @Inject constructor( } val dirs = storageManager.getWriteableDirs() runInterruptible(Dispatchers.IO) { - dirs.flatMap { dir -> - dir.children().filterWith(TempFileFilter()) - }.forEach { file -> - file.deleteRecursively() + val filter = TempFileFilter() + dirs.forEach { dir -> + dir.withChildren { children -> + children.forEach { child -> + if (filter.accept(child)) { + child.deleteRecursively() + } + } + } } } return true @@ -231,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 -> @@ -246,7 +253,7 @@ class LocalMangaRepository @Inject constructor( private suspend fun getAllFiles() = storageManager.getReadableDirs() .asSequence() .flatMap { dir -> - dir.children().filterNot { it.isHidden } + dir.withChildren { children -> children.filterNot { it.isHidden }.toList() } } private fun Collection.unwrap(): List = map { it.manga } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt index be36c273e..cb14266e6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt @@ -19,9 +19,9 @@ import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okhttp3.Cache import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_FILE import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.getStorageName +import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.resolveFile import org.koitharu.kotatsu.parsers.util.mapToSet import java.io.File @@ -90,7 +90,7 @@ class LocalStorageManager @Inject constructor( } suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) { - if (uri.scheme == URI_SCHEME_FILE) { + if (uri.isFileUri()) { uri.toFile() } else { uri.resolveFile(context) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt index 394746604..ae7daef04 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -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()) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt index a66b60956..dfc86c51d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local.data import android.content.Context import android.graphics.Bitmap import android.os.StatFs +import android.webkit.MimeTypeMap import com.tomclaw.cache.DiskLruCache import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -15,7 +16,7 @@ import okio.use import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.compressToPNG -import org.koitharu.kotatsu.core.util.ext.longHashCode +import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.subdir import org.koitharu.kotatsu.core.util.ext.takeIfReadable @@ -24,6 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -50,15 +52,15 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { }.getOrThrow() } - suspend fun get(url: String): File? { + suspend fun get(url: String): File? = withContext(Dispatchers.IO) { val cache = lruCache.get() - return runInterruptible(Dispatchers.IO) { + runInterruptible { cache.get(url)?.takeIfReadable() } } - suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) { - val file = File(cacheDir.get().parentFile, url.longHashCode().toString()) + suspend fun put(url: String, source: Source, mimeType: String?): File = withContext(Dispatchers.IO) { + val file = createBufferFile(url, mimeType) try { val bytes = file.sink(append = false).buffer().use { it.writeAllCancellable(source) @@ -66,17 +68,23 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { if (bytes == 0L) { throw NoDataReceivedException(url) } - lruCache.get().put(url, file) + val cache = lruCache.get() + runInterruptible { + cache.put(url, file) + } } finally { file.delete() } } suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) { - val file = File(cacheDir.get().parentFile, url.longHashCode().toString()) + val file = createBufferFile(url, "image/png") try { bitmap.compressToPNG(file) - lruCache.get().put(url, file) + val cache = lruCache.get() + runInterruptible { + cache.put(url, file) + } } finally { file.delete() } @@ -90,12 +98,24 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { } private suspend fun getAvailableSize(): Long = runCatchingCancellable { - val statFs = StatFs(cacheDir.get().absolutePath) - statFs.availableBytes + val dir = cacheDir.get() + runInterruptible(Dispatchers.IO) { + val statFs = StatFs(dir.absolutePath) + statFs.availableBytes + } }.onFailure { it.printStackTraceDebug() }.getOrDefault(SIZE_DEFAULT) + private suspend fun createBufferFile(url: String, mimeType: String?): File { + val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } + ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" } + val cacheDir = cacheDir.get() + val rootDir = checkNotNull(cacheDir.parentFile) { "Cannot get parent for ${cacheDir.absolutePath}" } + val name = UUID.randomUUID().toString() + "." + ext + return File(rootDir, name) + } + private companion object { val SIZE_MIN diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt index 88ad215e5..91eb31b7b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt @@ -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) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt index e7b6757d6..d2425be9e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt @@ -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() @@ -83,8 +83,13 @@ class LocalMangaIndex @Inject constructor( db.getLocalMangaIndexDao().delete(mangaId) } - suspend fun getAvailableTags(): List { - return db.getLocalMangaIndexDao().findTags() + suspend fun getAvailableTags(skipNsfw: Boolean): List { + val dao = db.getLocalMangaIndexDao() + return if (skipNsfw) { + dao.findTags(isNsfw = false) + } else { + dao.findTags() + } } private suspend fun upsert(manga: LocalManga) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt index 8b1b6f1b0..21038c6ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndexDao.kt @@ -13,6 +13,9 @@ interface LocalMangaIndexDao { @Query("SELECT title FROM local_index LEFT JOIN manga_tags ON manga_tags.manga_id = local_index.manga_id LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id WHERE title IS NOT NULL GROUP BY title") suspend fun findTags(): List + @Query("SELECT title FROM local_index LEFT JOIN manga_tags ON manga_tags.manga_id = local_index.manga_id LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id WHERE (SELECT nsfw FROM manga WHERE manga.manga_id = local_index.manga_id) = :isNsfw AND title IS NOT NULL GROUP BY title") + suspend fun findTags(isNsfw: Boolean): List + @Upsert suspend fun upsert(entity: LocalMangaIndexEntity) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt deleted file mode 100644 index 3667f1e31..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt +++ /dev/null @@ -1,158 +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.children -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.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 = runInterruptible(Dispatchers.IO) { - val file = chapter.url.toUri().toFile() - if (file.isDirectory) { - file.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 && children().any { hasImageExtension(it) } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt deleted file mode 100644 index 13d129235..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt +++ /dev/null @@ -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.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 - - 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, 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 = - Uri.fromParts("cbz", file.path, entryName).toString() - - @JvmStatic - protected fun Manga.copy2( - url: String, - coverUrl: String, - largeCoverUrl: String, - chapters: List?, - 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, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt new file mode 100644 index 000000000..02ffd9381 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt @@ -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 = 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, 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 { + 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? = 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, + ) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt deleted file mode 100644 index ec33cd83c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt +++ /dev/null @@ -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() - 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 { - 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): 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 - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt index dc9904cfc..4c1cbff62 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt @@ -6,12 +6,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.core.model.isLocal 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 @@ -90,12 +91,12 @@ class LocalMangaDirOutput( override fun close() { for (output in chaptersOutput.values) { - output.close() + output.closeQuietly() } } suspend fun deleteChapters(ids: Set) = 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() @@ -119,17 +120,28 @@ class LocalMangaDirOutput( } private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) { - finish() - close() - val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP)) - file.renameTo(resFile) + val e: Throwable? = try { + finish() + null + } catch (e: Throwable) { + e + } finally { + close() + } + if (e == null) { + val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP)) + file.renameTo(resFile) + } else { + file.delete() + throw e + } } private fun chapterFileName(chapter: IndexedValue): String { index.getChapterFileName(chapter.value.id)?.let { return it } - val baseName = "${chapter.index}_${chapter.value.name.toFileNameSafe()}".take(18) + val baseName = "${chapter.index}_${chapter.value.name.toFileNameSafe()}".take(32) var i = 0 while (true) { val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt index b94c04ddf..6db94d18d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt @@ -4,11 +4,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import okhttp3.internal.format 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 @@ -101,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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt index 08df5832a..011f4ff6f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt @@ -2,8 +2,6 @@ package org.koitharu.kotatsu.local.data.output import androidx.core.net.toFile import androidx.core.net.toUri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.parsers.model.Manga @@ -16,26 +14,14 @@ class LocalMangaUtil( } suspend fun deleteChapters(ids: Set) { - newOutput().use { output -> - when (output) { - is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) { - LocalMangaZipOutput.filterChapters(output, ids) - } - - is LocalMangaDirOutput -> { - output.deleteChapters(ids) - output.finish() - } - } - } - } - - private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) { val file = manga.url.toUri().toFile() if (file.isDirectory) { - LocalMangaDirOutput(file, manga) + LocalMangaDirOutput(file, manga).use { output -> + output.deleteChapters(ids) + output.finish() + } } else { - LocalMangaZipOutput(file, manga) + LocalMangaZipOutput.filterChapters(file, manga, ids) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt index d4370c3f6..eded64595 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.readText @@ -52,27 +53,29 @@ class LocalMangaZipOutput( index.setCoverEntry(name) } - override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = mutex.withLock { - val name = buildString { - append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) - if (ext.isNotEmpty() && ext.length <= 4) { - append('.') - append(ext) + override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = + mutex.withLock { + val name = buildString { + append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } } + runInterruptible(Dispatchers.IO) { + output.put(name, file) + } + index.addChapter(chapter, null) } - runInterruptible(Dispatchers.IO) { - output.put(name, file) - } - index.addChapter(chapter, null) - } override suspend fun flushChapter(chapter: MangaChapter): Boolean = false override suspend fun finish() = mutex.withLock { runInterruptible(Dispatchers.IO) { - output.put(ENTRY_NAME_INDEX, index.toString()) - output.finish() - output.close() + output.use { output -> + output.put(ENTRY_NAME_INDEX, index.toString()) + output.finish() + } } rootFile.deleteAwait() output.file.renameTo(rootFile) @@ -115,42 +118,53 @@ class LocalMangaZipOutput( private const val FILENAME_PATTERN = "%08d_%03d%03d" - @WorkerThread - fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set) { - ZipFile(subject.rootFile).use { zip -> - val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX))) - idsToRemove.forEach { id -> index.removeChapter(id) } - val patterns = requireNotNull(index.getMangaInfo()?.chapters).map { - index.getChapterNamesPattern(it) - } - val coverEntryName = index.getCoverEntry() - for (entry in zip.entries()) { - when { - entry.name == ENTRY_NAME_INDEX -> { - subject.output.put(ENTRY_NAME_INDEX, index.toString()) - } - - entry.isDirectory -> { - subject.output.addDirectory(entry.name) - } - - entry.name == coverEntryName -> { - subject.output.copyEntryFrom(zip, entry) + suspend fun filterChapters(file: File, manga: Manga, idsToRemove: Set) = + runInterruptible(Dispatchers.IO) { + val subject = LocalMangaZipOutput(file, manga) + try { + ZipFile(subject.rootFile).use { zip -> + val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX))) + idsToRemove.forEach { id -> index.removeChapter(id) } + val patterns = requireNotNull(index.getMangaInfo()?.chapters).map { + index.getChapterNamesPattern(it) } - - else -> { - val name = entry.name.substringBefore('.') - if (patterns.any { it.matches(name) }) { - subject.output.copyEntryFrom(zip, entry) + val coverEntryName = index.getCoverEntry() + for (entry in zip.entries()) { + when { + entry.name == ENTRY_NAME_INDEX -> { + subject.output.put(ENTRY_NAME_INDEX, index.toString()) + } + + entry.isDirectory -> { + subject.output.addDirectory(entry.name) + } + + entry.name == coverEntryName -> { + subject.output.copyEntryFrom(zip, entry) + } + + else -> { + val name = entry.name.substringBefore('.') + if (patterns.any { it.matches(name) }) { + subject.output.copyEntryFrom(zip, entry) + } + } } } + subject.output.finish() + subject.output.close() + subject.rootFile.delete() + subject.output.file.renameTo(subject.rootFile) } + } catch (e: Throwable) { + subject.closeQuietly() + try { + subject.output.file.delete() + } catch (e2: Throwable) { + e.addSuppressed(e2) + } + throw e } - subject.output.finish() - subject.output.close() - subject.rootFile.delete() - subject.output.file.renameTo(subject.rootFile) } - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt deleted file mode 100644 index b83867e5c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.local.data.util - -import okhttp3.internal.closeQuietly -import okio.Closeable -import okio.Source - -private class ExtraCloseableSource( - private val delegate: Source, - private val extraCloseable: Closeable, -) : Source by delegate { - - override fun close() { - try { - delegate.close() - } finally { - extraCloseable.closeQuietly() - } - } -} - -fun Source.withExtraCloseable(closeable: Closeable): Source = ExtraCloseableSource(this, closeable) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt index 877395a40..d19b724ba 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt @@ -10,18 +10,22 @@ 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 import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository 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.recoverCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject class DeleteReadChaptersUseCase @Inject constructor( private val localMangaRepository: LocalMangaRepository, private val historyRepository: HistoryRepository, + private val mangaRepositoryFactory: MangaRepository.Factory, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, ) { @@ -68,8 +72,8 @@ class DeleteReadChaptersUseCase @Inject constructor( private suspend fun getDeletionTask(manga: LocalManga): DeletionTask? { val history = historyRepository.getOne(manga.manga) ?: return null - val chapters = manga.manga.chapters ?: localMangaRepository.getDetails(manga.manga).chapters - if (chapters.isNullOrEmpty()) { + val chapters = getAllChapters(manga) + if (chapters.isEmpty()) { return null } val branch = (chapters.findById(history.chapterId) ?: return null).branch @@ -89,6 +93,21 @@ class DeleteReadChaptersUseCase @Inject constructor( localStorageChanges.emit(subject.copy(manga = updated)) } + private suspend fun getAllChapters(manga: LocalManga): List = runCatchingCancellable { + val remoteManga = checkNotNull(localMangaRepository.getRemoteManga(manga.manga)) + checkNotNull(mangaRepositoryFactory.create(remoteManga.source).getDetails(remoteManga).chapters) + }.recoverCatchingCancellable { + checkNotNull( + manga.manga.chapters.let { + if (it.isNullOrEmpty()) { + localMangaRepository.getDetails(manga.manga).chapters + } else { + it + } + }, + ) + }.getOrDefault(manga.manga.chapters.orEmpty()) + private class DeletionTask( val manga: LocalManga, val chaptersIds: Set, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt index 38fe8ed21..2b535e28b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/LocalObserveMapper.kt @@ -29,7 +29,7 @@ abstract class LocalObserveMapper( 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) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt index 2d8e8941e..ed0749933 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt @@ -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 || diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt index f033847c2..c1a6f010b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt @@ -11,10 +11,9 @@ import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat -import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat -import coil.ImageLoader -import coil.request.ImageRequest +import coil3.ImageLoader +import coil3.request.ImageRequest import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R @@ -22,6 +21,7 @@ import org.koitharu.kotatsu.core.ErrorReporterReceiver import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull @@ -47,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) @@ -71,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) @@ -94,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, @@ -113,7 +108,7 @@ class ImportService : CoroutineIntentService() { coil.execute( ImageRequest.Builder(applicationContext) .data(manga.coverUrl) - .tag(manga.source) + .mangaSourceExtra(manga.source) .build(), ).toBitmapOrNull(), ) @@ -139,11 +134,13 @@ class ImportService : CoroutineIntentService() { notification.setContentTitle(applicationContext.getString(R.string.error_occurred)) .setContentText(error.getDisplayMessage(applicationContext.resources)) .setSmallIcon(android.R.drawable.stat_notify_error) - .addAction( + ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent -> + notification.addAction( R.drawable.ic_alert_outline, applicationContext.getString(R.string.report), - ErrorReporterReceiver.getPendingIntent(applicationContext, error), + reportIntent, ) + } } return notification.build() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt index 6078bac27..6c4625f31 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt @@ -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(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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt index 72e287b17..aae893944 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt @@ -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 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index d374e3360..170e87ec3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -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(binding.root.context, childFragmentManager, this::onEmptyActionClick)) addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel)) viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt index 882ea4646..88b560370 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt @@ -5,11 +5,14 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider +import androidx.fragment.app.FragmentManager 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 fragmentManager: FragmentManager, private val onImportClick: Function0, ) : MenuProvider { @@ -29,6 +32,11 @@ class LocalListMenuProvider( true } + R.id.action_filter -> { + FilterSheetFragment.show(fragmentManager) + true + } + else -> false } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt index fcf77379c..5e2a2e8cd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt @@ -1,27 +1,23 @@ package org.koitharu.kotatsu.main.domain import androidx.collection.ArraySet -import coil.intercept.Interceptor -import coil.network.HttpException -import coil.request.ErrorResult -import coil.request.ImageResult -import okio.FileNotFoundException -import org.jsoup.HttpStatusException +import coil3.intercept.Interceptor +import coil3.request.ErrorResult +import coil3.request.ImageResult 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 -import org.koitharu.kotatsu.core.parser.ParserMangaRepository +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.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, @@ -33,18 +29,18 @@ class CoverRestoreInterceptor @Inject constructor( override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val request = chain.request - val result = chain.proceed(request) + val result = chain.proceed() if (result is ErrorResult && result.throwable.shouldRestore()) { - request.tags.tag()?.let { + request.extras[mangaKey]?.let { if (restoreManga(it)) { - return chain.proceed(request.newBuilder().build()) + return chain.withRequest(request.newBuilder().build()).proceed() } else { return result } } - request.tags.tag()?.let { + request.extras[bookmarkKey]?.let { if (restoreBookmark(it)) { - return chain.proceed(request.newBuilder().build()) + return chain.withRequest(request.newBuilder().build()).proceed() } else { return result } @@ -70,10 +66,10 @@ class CoverRestoreInterceptor @Inject constructor( } private suspend fun restoreMangaImpl(manga: Manga): Boolean { - if (dataRepository.findMangaById(manga.id) == null) { + if (dataRepository.findMangaById(manga.id) == null || manga.isLocal) { return false } - val repo = repositoryFactory.create(manga.source) as? ParserMangaRepository ?: return false + val repo = repositoryFactory.create(manga.source) val fixed = repo.find(manga) ?: return false return if (fixed != manga) { dataRepository.storeManga(fixed) @@ -100,7 +96,10 @@ class CoverRestoreInterceptor @Inject constructor( } private suspend fun restoreBookmarkImpl(bookmark: Bookmark): Boolean { - val repo = repositoryFactory.create(bookmark.manga.source) as? ParserMangaRepository ?: return false + if (bookmark.manga.isLocal) { + return false + } + val repo = repositoryFactory.create(bookmark.manga.source) val chapter = repo.getDetails(bookmark.manga).chapters?.findById(bookmark.chapterId) ?: return false val page = repo.getPages(chapter)[bookmark.page] val imageUrl = page.preview.ifNullOrEmpty { page.url } @@ -113,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 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 384fbd453..87181e871 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -55,7 +55,7 @@ import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment -import org.koitharu.kotatsu.local.data.index.LocalMangaIndex +import org.koitharu.kotatsu.local.ui.LocalIndexUpdateService import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner @@ -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 @@ -352,7 +353,8 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav MangaPrefetchService.prefetchLast(this@MainActivity) requestNotificationsPermission() } - startService(Intent(this@MainActivity, LocalMangaIndex::class.java)) + startService(Intent(this@MainActivity, LocalIndexUpdateService::class.java)) + startService(Intent(this@MainActivity, PeriodicalBackupService::class.java)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt index b9a407a4e..77af614f5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt @@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings 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.parsers.util.isNumeric import org.koitharu.kotatsu.parsers.util.md5 import javax.inject.Inject diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt index 9870d4d9c..2eceea6d5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt @@ -14,9 +14,9 @@ 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.ReaderMode +import org.koitharu.kotatsu.core.util.ext.isFileUri +import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.local.data.isFileUri -import org.koitharu.kotatsu.local.data.isZipUri import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.runCatchingCancellable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 14e7cccc2..6ad489c49 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -1,8 +1,6 @@ package org.koitharu.kotatsu.reader.domain import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.graphics.Rect import android.net.Uri import androidx.annotation.AnyThread @@ -29,11 +27,13 @@ import kotlinx.coroutines.sync.withPermit import okhttp3.OkHttpClient import okhttp3.Request import okio.use +import org.jetbrains.annotations.Blocking +import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor +import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope @@ -42,22 +42,26 @@ import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin import org.koitharu.kotatsu.core.util.ext.compressToPNG import org.koitharu.kotatsu.core.util.ext.ensureRamAtLeast import org.koitharu.kotatsu.core.util.ext.ensureSuccess -import org.koitharu.kotatsu.core.util.ext.exists import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull +import org.koitharu.kotatsu.core.util.ext.isFileUri +import org.koitharu.kotatsu.core.util.ext.isNotEmpty import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode -import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty +import org.koitharu.kotatsu.core.util.ext.isZipUri +import org.koitharu.kotatsu.core.util.ext.mimeType import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.ramAvailable import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred +import org.koitharu.kotatsu.download.ui.worker.DownloadSlowdownDispatcher import org.koitharu.kotatsu.local.data.PagesCache -import org.koitharu.kotatsu.local.data.isFileUri -import org.koitharu.kotatsu.local.data.isZipUri import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.mimeType +import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import java.io.File import java.util.LinkedList import java.util.concurrent.atomic.AtomicInteger import java.util.zip.ZipFile @@ -75,6 +79,7 @@ class PageLoader @Inject constructor( private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, private val imageProxyInterceptor: ImageProxyInterceptor, + private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher, ) { val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default @@ -92,7 +97,7 @@ class PageLoader @Inject constructor( private val edgeDetector = EdgeDetector(context) fun isPrefetchApplicable(): Boolean { - return repository is ParserMangaRepository + return repository is CachingMangaRepository && settings.isPagesPreloadEnabled && !context.isPowerSaveMode() && !isLowRam() @@ -123,7 +128,7 @@ class PageLoader @Inject constructor( } else if (task?.isCancelled == false) { return task } - task = loadPageAsyncImpl(page, force) + task = loadPageAsyncImpl(page, skipCache = force, isPrefetch = false) synchronized(tasks) { tasks[page.id] = task } @@ -140,17 +145,17 @@ class PageLoader @Inject constructor( ZipFile(uri.schemeSpecificPart).use { zip -> val entry = zip.getEntry(uri.fragment) context.ensureRamAtLeast(entry.size * 2) - zip.getInputStream(zip.getEntry(uri.fragment)).use { - checkBitmapNotNull(BitmapFactory.decodeStream(it)) + zip.getInputStream(entry).use { + BitmapDecoderCompat.decode(it, entry.mimeType) } } } cache.put(uri.toString(), bitmap).toUri() } else { val file = uri.toFile() - context.ensureRamAtLeast(file.length() * 2) runInterruptible(Dispatchers.IO) { - checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath)) + context.ensureRamAtLeast(file.length() * 2) + BitmapDecoderCompat.decode(file) }.use { image -> image.compressToPNG(file) } @@ -182,7 +187,7 @@ class PageLoader @Inject constructor( val page = prefetchQueue.pollFirst() ?: return@launch if (cache.get(page.url) == null) { synchronized(tasks) { - tasks[page.id] = loadPageAsyncImpl(page, false) + tasks[page.id] = loadPageAsyncImpl(page, skipCache = false, isPrefetch = true) } return@launch } @@ -190,7 +195,11 @@ class PageLoader @Inject constructor( } } - private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred { + private fun loadPageAsyncImpl( + page: MangaPage, + skipCache: Boolean, + isPrefetch: Boolean, + ): ProgressDeferred { val progress = MutableStateFlow(PROGRESS_UNDEFINED) val deferred = loaderScope.async { if (!skipCache) { @@ -198,7 +207,7 @@ class PageLoader @Inject constructor( } counter.incrementAndGet() try { - loadPageImpl(page, progress) + loadPageImpl(page, progress, isPrefetch) } finally { if (counter.decrementAndGet() == 0) { onIdle() @@ -218,7 +227,11 @@ class PageLoader @Inject constructor( } } - private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow): Uri = semaphore.withPermit { + private suspend fun loadPageImpl( + page: MangaPage, + progress: MutableStateFlow, + isPrefetch: Boolean, + ): Uri = semaphore.withPermit { val pageUrl = getPageUrl(page) check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" } val uri = Uri.parse(pageUrl) @@ -231,11 +244,13 @@ class PageLoader @Inject constructor( uri.isFileUri() -> uri else -> { + if (isPrefetch) { + downloadSlowdownDispatcher.delay(page.source) + } val request = createPageRequest(pageUrl, page.source) imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> - val body = checkNotNull(response.body) { "Null response body" } - body.withProgress(progress).use { - cache.put(pageUrl, it.source()) + response.requireBody().withProgress(progress).use { + cache.put(pageUrl, it.source(), response.mimeType) } }.toUri() } @@ -246,8 +261,6 @@ class PageLoader @Inject constructor( return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) } - private fun checkBitmapNotNull(bitmap: Bitmap?): Bitmap = checkNotNull(bitmap) { "Cannot decode bitmap" } - private fun Deferred.isValid(): Boolean { return getCompletionResultOrNull()?.map { uri -> uri.exists() && uri.isTargetNotEmpty() @@ -275,5 +288,28 @@ class PageLoader @Inject constructor( .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) .tag(MangaSource::class.java, mangaSource) .build() + + + @Blocking + private fun Uri.exists(): Boolean = when { + isFileUri() -> toFile().exists() + isZipUri() -> { + val file = File(requireNotNull(schemeSpecificPart)) + file.exists() && ZipFile(file).use { it.getEntry(fragment) != null } + } + + else -> false + } + + @Blocking + private fun Uri.isTargetNotEmpty(): Boolean = when { + isFileUri() -> toFile().isNotEmpty() + isZipUri() -> { + val file = File(requireNotNull(schemeSpecificPart)) + file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L } + } + + else -> false + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index 9a8f4b43d..059f4a824 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -4,94 +4,120 @@ import android.content.Context import android.graphics.BitmapFactory import android.net.Uri import android.webkit.MimeTypeMap +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.net.toFile import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import okhttp3.HttpUrl.Companion.toHttpUrl +import okio.FileSystem import okio.IOException +import okio.Path.Companion.toPath +import okio.Source import okio.buffer +import okio.openZip import okio.sink +import okio.source import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.source +import org.koitharu.kotatsu.core.util.ext.isFileUri +import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.writeAllCancellable +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.toFileNameSafe import org.koitharu.kotatsu.reader.domain.PageLoader import java.io.File -import javax.inject.Inject -import kotlin.coroutines.Continuation +import java.text.SimpleDateFormat +import java.util.Date +import javax.inject.Provider import kotlin.coroutines.resume -private const val MAX_FILENAME_LENGTH = 10 -private const val EXTENSION_FALLBACK = "png" - -class PageSaveHelper @Inject constructor( +class PageSaveHelper @AssistedInject constructor( + @Assisted activityResultCaller: ActivityResultCaller, @ApplicationContext private val context: Context, private val settings: AppSettings, -) { - - private var continuation: Continuation? = null - private val contentResolver = context.contentResolver - - suspend fun savePage( - pageLoader: PageLoader, - page: MangaPage, - saveLauncher: ActivityResultLauncher, - ): Uri { - val pageUrl = pageLoader.getPageUrl(page) - val pageUri = pageLoader.loadPage(page, force = false) - val proposedName = getProposedFileName(pageUrl, pageUri) - val destination = getDefaultFileUri(proposedName) ?: pickFileUri(saveLauncher, proposedName) - runInterruptible(Dispatchers.IO) { - contentResolver.openOutputStream(destination)?.sink()?.buffer() - }?.use { output -> - pageUri.source().use { input -> - output.writeAllCancellable(input) + private val pageLoaderProvider: Provider, +) : ActivityResultCallback { + + private val savePageRequest = activityResultCaller.registerForActivityResult(PageSaveContract(), this) + private val pickDirectoryRequest = + activityResultCaller.registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this) + + private var continuation: CancellableContinuation? = null + + override fun onActivityResult(result: Uri?) { + continuation?.also { cont -> + if (result != null) { + cont.resume(result) + } else { + cont.cancel() } - } ?: throw IOException("Output stream is null") - return destination + } } - private fun getDefaultFileUri(proposedName: String): Uri? { - if (settings.isPagesSavingAskEnabled) { - return null - } - return settings.getPagesSaveDir(context)?.let { - val ext = proposedName.substringAfterLast('.', "") - val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null - it.createFile(mime, proposedName.substringBeforeLast('.'))?.uri + suspend fun save(tasks: Set): Uri? = when (tasks.size) { + 0 -> null + 1 -> saveImpl(tasks.first()) + else -> { + saveImpl(tasks) + null } } - private suspend fun pickFileUri(saveLauncher: ActivityResultLauncher, proposedName: String): Uri { - val defaultUri = settings.getPagesSaveDir(context)?.uri?.buildUpon()?.appendPath(proposedName)?.toString() - return withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - continuation = cont - saveLauncher.launch(defaultUri ?: proposedName) - }.also { - continuation = null - } + private suspend fun saveImpl(task: Task): Uri { + val pageLoader = pageLoaderProvider.get() + val pageUrl = pageLoader.getPageUrl(task.page).toUri() + val pageUri = pageLoader.loadPage(task.page, force = false) + val proposedName = task.getFileBaseName() + "." + getPageExtension(pageUrl, pageUri) + val destination = getDefaultFileUri(proposedName)?.uri ?: run { + val defaultUri = settings.getPagesSaveDir(context)?.uri?.buildUpon()?.appendPath(proposedName)?.toString() + savePageRequest.launchAndAwait(defaultUri ?: proposedName) } + copyImpl(pageUri, destination) + return destination } - fun onActivityResult(uri: Uri): Boolean = continuation?.apply { - resume(uri) - } != null + private suspend fun saveImpl(tasks: Collection) { + val pageLoader = pageLoaderProvider.get() + val destinationDir = getDefaultFileUri(null) ?: run { + val defaultUri = settings.getPagesSaveDir(context)?.uri + DocumentFile.fromTreeUri(context, pickDirectoryRequest.launchAndAwait(defaultUri)) + } ?: throw IOException("Cannot get destination directory") - private suspend fun getProposedFileName(url: String, fileUri: Uri): String { - var name = if (url.startsWith("cbz:")) { - requireNotNull(url.toUri().fragment) - } else { - url.toHttpUrl().pathSegments.last() + for (task in tasks) { + val pageUrl = pageLoader.getPageUrl(task.page).toUri() + val pageUri = pageLoader.loadPage(task.page, force = false) + val proposedName = task.getFileBaseName() + val ext = getPageExtension(pageUrl, pageUri) + val mime = requireNotNull(MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)) { + "Unknown type of $proposedName" + } + val destination = destinationDir.createFile(mime, proposedName.substringBeforeLast('.')) + copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file")) } + } + + private suspend fun getPageExtension(url: Uri, fileUri: Uri): String { + val name = requireNotNull( + if (url.isZipUri()) { + url.fragment?.substringAfterLast(File.separatorChar) + } else { + url.lastPathSegment + }, + ) { "Invalid page url: $url" } var extension = name.substringAfterLast('.', "") - name = name.substringBeforeLast('.') if (extension.length !in 2..4) { val mimeType = fileUri.toFileOrNull()?.let { file -> getImageMimeType(file) } extension = if (mimeType != null) { @@ -100,7 +126,53 @@ class PageSaveHelper @Inject constructor( EXTENSION_FALLBACK } } - return name.toFileNameSafe().take(MAX_FILENAME_LENGTH) + "." + extension + return extension + } + + private suspend fun ActivityResultLauncher.launchAndAwait(input: I): Uri { + continuation?.cancel() + return withContext(Dispatchers.Main) { + try { + suspendCancellableCoroutine { cont -> + continuation = cont + launch(input) + } + } finally { + continuation = null + } + } + } + + private fun getDefaultFileUri(proposedName: String?): DocumentFile? { + if (settings.isPagesSavingAskEnabled) { + return null + } + val dir = settings.getPagesSaveDir(context) ?: return null + if (proposedName == null) { + return dir + } else { + val ext = proposedName.substringAfterLast('.', "") + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null + return dir.createFile(mime, proposedName.substringBeforeLast('.')) + } + } + + private fun getSource(uri: Uri): Source = when { + uri.isFileUri() -> uri.toFile().source() + uri.isZipUri() -> FileSystem.SYSTEM.openZip(uri.schemeSpecificPart.toPath()) + .source(requireNotNull(uri.fragment).toPath()) + + else -> throw IllegalArgumentException("Bad uri $uri: unsupported scheme") + } + + private suspend fun copyImpl(source: Uri, destination: Uri) = withContext(Dispatchers.IO) { + runInterruptible { + context.contentResolver.openOutputStream(destination) ?: throw IOException("Output stream is null") + }.sink().buffer().use { sink -> + getSource(source).use { input -> + sink.writeAllCancellable(input) + } + } } private suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) { @@ -110,4 +182,34 @@ class PageSaveHelper @Inject constructor( BitmapFactory.decodeFile(file.path, options)?.recycle() options.outMimeType } + + data class Task( + val manga: Manga, + val chapter: MangaChapter, + val pageNumber: Int, + val page: MangaPage, + ) { + + fun getFileBaseName() = buildString { + append(manga.title.toFileNameSafe().take(MAX_BASENAME_LENGTH)) + append('-') + append(chapter.number) + append('-') + append(pageNumber) + append('_') + append(SimpleDateFormat("yyyy-MM-dd_HHmm").format(Date())) + } + } + + @AssistedFactory + interface Factory { + + fun create(activityResultCaller: ActivityResultCaller): PageSaveHelper + } + + private companion object { + + private const val MAX_BASENAME_LENGTH = 12 + private const val EXTENSION_FALLBACK = "png" + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 2e02fa6f7..e59e4d019 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -14,7 +14,6 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager -import androidx.activity.result.ActivityResultCallback import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.OnApplyWindowInsetsListener @@ -74,7 +73,6 @@ class ReaderActivity : OnApplyWindowInsetsListener, ReaderNavigationCallback, IdlingDetector.Callback, - ActivityResultCallback, ZoomControl.ZoomControlListener { @Inject @@ -83,8 +81,16 @@ class ReaderActivity : @Inject lateinit var tapGridSettings: TapGridSettings + @Inject + lateinit var pageSaveHelperFactory: PageSaveHelper.Factory + + @Inject + lateinit var scrollTimerFactory: ScrollTimer.Factory + + @Inject + lateinit var screenOrientationHelper: ScreenOrientationHelper + private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this) - private val savePageRequest = registerForActivityResult(PageSaveContract(), this) private val viewModel: ReaderViewModel by viewModels() @@ -97,10 +103,8 @@ class ReaderActivity : scrollTimer.isEnabled = value } - @Inject - lateinit var scrollTimerFactory: ScrollTimer.Factory - private lateinit var scrollTimer: ScrollTimer + private lateinit var pageSaveHelper: PageSaveHelper private lateinit var touchHelper: TapGridDispatcher private lateinit var controlDelegate: ReaderControlDelegate private var gestureInsets: Insets = Insets.NONE @@ -110,10 +114,12 @@ class ReaderActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityReaderBinding.inflate(layoutInflater)) + screenOrientationHelper.init(settings.readerScreenOrientation) readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings) supportActionBar?.setDisplayHomeAsUpEnabled(true) touchHelper = TapGridDispatcher(this, this) scrollTimer = scrollTimerFactory.create(this, this) + pageSaveHelper = pageSaveHelperFactory.create(this) controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this) viewBinding.slider.setLabelFormatter(PageLabelFormatter()) viewBinding.zoomControl.listener = this @@ -159,10 +165,6 @@ class ReaderActivity : viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel)) } - override fun onActivityResult(result: Uri?) { - viewModel.onActivityResult(result) - } - override fun getParentActivityIntent(): Intent? { val manga = viewModel.getMangaOrNull() ?: return null return DetailsActivity.newIntent(this, manga) @@ -288,15 +290,14 @@ class ReaderActivity : } private fun onPageSaved(uri: Uri?) { + val snackbar = Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG) if (uri != null) { - Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG) - .setAction(R.string.share) { - ShareHelper(this).shareImage(uri) - } - } else { - Snackbar.make(viewBinding.container, R.string.error_occurred, Snackbar.LENGTH_SHORT) - }.setAnchorView(viewBinding.appbarBottom) - .show() + snackbar.setAction(R.string.share) { + ShareHelper(this).shareImage(uri) + } + } + snackbar.setAnchorView(viewBinding.appbarBottom) + snackbar.show() } private fun setKeepScreenOn(isKeep: Boolean) { @@ -379,8 +380,7 @@ class ReaderActivity : } override fun onSavePageClick() { - val page = viewModel.getCurrentPage() ?: return - viewModel.saveCurrentPage(page, savePageRequest) + viewModel.saveCurrentPage(pageSaveHelper) } private fun onReaderBarChanged(isBarEnabled: Boolean) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt index bb045deb8..53069dd1b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt @@ -19,7 +19,7 @@ import java.util.EnumMap class ReaderManager( private val fragmentManager: FragmentManager, private val container: FragmentContainerView, - private val settings: AppSettings, + settings: AppSettings, ) { private val modeMap = EnumMap>>(ReaderMode::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index a0341f7ba..c1a5c70f3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -1,19 +1,16 @@ package org.koitharu.kotatsu.reader.ui import android.net.Uri -import androidx.activity.result.ActivityResultLauncher import androidx.annotation.AnyThread import androidx.annotation.MainThread import androidx.annotation.WorkerThread import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -45,7 +42,6 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.sizeOrZero import org.koitharu.kotatsu.details.data.MangaDetails @@ -81,7 +77,6 @@ class ReaderViewModel @Inject constructor( private val historyRepository: HistoryRepository, private val bookmarksRepository: BookmarksRepository, settings: AppSettings, - private val pageSaveHelper: PageSaveHelper, private val pageLoader: PageLoader, private val chaptersLoader: ChaptersLoader, private val appShortcutManager: AppShortcutManager, @@ -257,30 +252,21 @@ class ReaderViewModel @Inject constructor( } fun saveCurrentPage( - page: MangaPage, - saveLauncher: ActivityResultLauncher, + pageSaveHelper: PageSaveHelper ) { val prevJob = pageSaveJob pageSaveJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() - try { - val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher) - onPageSaved.call(dest) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - e.printStackTraceDebug() - onPageSaved.call(null) - } - } - } - - fun onActivityResult(uri: Uri?) { - if (uri != null) { - pageSaveHelper.onActivityResult(uri) - } else { - pageSaveJob?.cancel() - pageSaveJob = null + val state = checkNotNull(getCurrentState()) + val currentManga = manga.requireValue() + val task = PageSaveHelper.Task( + manga = currentManga, + chapter = checkNotNull(currentManga.findChapter(state.chapterId)), + pageNumber = state.page, + page = checkNotNull(getCurrentPage()) { "Cannot find current page" }, + ) + val dest = pageSaveHelper.save(setOf(task)) + onPageSaved.call(dest) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ScreenOrientationHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt similarity index 84% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/ScreenOrientationHelper.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt index 79a071842..546577b5a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ScreenOrientationHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util +package org.koitharu.kotatsu.reader.ui import android.app.Activity import android.content.pm.ActivityInfo @@ -10,6 +10,7 @@ import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart import javax.inject.Inject @@ -44,6 +45,13 @@ class ScreenOrientationHelper @Inject constructor(private val activity: Activity } } + fun init(orientation: Int) { + if (activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { + // https://developer.android.com/reference/android/R.attr.html#screenOrientation + activity.requestedOrientation = orientation + } + } + fun observeAutoOrientation() = callbackFlow { val observer = object : ContentObserver(Handler(activity.mainLooper)) { override fun onChange(selfChange: Boolean) { @@ -59,4 +67,5 @@ class ScreenOrientationHelper @Inject constructor(private val activity: Activity }.onStart { emit(isAutoRotationEnabled) }.distinctUntilChanged() + .conflate() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt index 8fb632c59..c9444532a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt @@ -12,10 +12,12 @@ import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding -import coil.ImageLoader -import coil.request.ImageRequest -import coil.size.Scale -import coil.size.ViewSizeResolver +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.bitmapConfig +import coil3.request.error +import coil3.size.Scale +import coil3.size.ViewSizeResolver import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.Slider @@ -27,6 +29,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.indicator +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setChecked @@ -139,7 +142,7 @@ class ColorFilterConfigActivity : .data(data) .scale(Scale.FILL) .decodeRegion() - .tag(page.source) + .mangaSourceExtra(page.source) .bitmapConfig(if (viewModel.is32BitColorsEnabled) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565) .indicator(listOf(viewBinding.progressBefore, viewBinding.progressAfter)) .error(R.drawable.ic_error_placeholder) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt index 28bf4f38c..eeaf9e1ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.reader.ui.colorfilter import android.graphics.drawable.Drawable import android.widget.ImageView -import coil.target.ImageViewTarget +import coil3.target.ImageViewTarget class DoubleViewTarget( primaryView: ImageView, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt index 6aad038a9..37da96afe 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt @@ -24,7 +24,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.util.ScreenOrientationHelper import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.showDistinct @@ -33,6 +32,7 @@ import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderViewModel +import org.koitharu.kotatsu.reader.ui.ScreenOrientationHelper import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity import org.koitharu.kotatsu.settings.SettingsActivity import javax.inject.Inject diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt index 04fa68a81..c4ac87358 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt @@ -7,7 +7,6 @@ import androidx.viewbinding.ViewBinding import org.koitharu.kotatsu.core.prefs.ReaderAnimation import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.widgets.ZoomControl -import org.koitharu.kotatsu.core.util.ext.getParcelableCompat import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.reader.ui.ReaderState diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index 3cfcea351..7c7ec895f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -152,6 +152,7 @@ class PageHolderDelegate( } catch (ce: CancellationException) { throw ce } catch (e2: Throwable) { + e2.printStackTrace() e.addSuppressed(e2) state = State.ERROR callback.onError(e) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index 21a0d5639..e901d9079 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.isLowRamDevice +import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.reader.domain.PageLoader @@ -154,6 +155,7 @@ open class PageHolder( bindingInfo.buttonRetry.setText( ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }, ) + bindingInfo.buttonErrorDetails.isVisible = e.isSerializable() bindingInfo.layoutError.isVisible = true bindingInfo.progressBar.hide() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index e2629e4a1..31501e97d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -11,6 +11,7 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.parsers.util.ifZero import org.koitharu.kotatsu.reader.domain.PageLoader @@ -128,6 +129,7 @@ class WebtoonHolder( bindingInfo.buttonRetry.setText( ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }, ) + bindingInfo.buttonErrorDetails.isVisible = e.isSerializable() bindingInfo.layoutError.isVisible = true bindingInfo.progressBar.hide() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt index d532aae06..460cc4856 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import android.view.animation.DecelerateInterpolator +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.coroutineScope @@ -13,7 +14,6 @@ import kotlinx.coroutines.yield import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher -import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.removeItemDecoration @@ -127,14 +127,13 @@ class WebtoonReaderFragment : BaseReaderFragment() } override fun getCurrentState(): ReaderState? = viewBinding?.run { - val currentItem = recyclerView.findCenterViewPosition() + val currentItem = recyclerView.findCurrentPagePosition() val adapter = recyclerView.adapter as? BaseReaderAdapter<*> val page = adapter?.getItemOrNull(currentItem) ?: return@run null ReaderState( chapterId = page.chapterId, page = page.index, - scroll = (recyclerView.findViewHolderForAdapterPosition(currentItem) as? WebtoonHolder) - ?.getScrollY() ?: 0, + scroll = (recyclerView.findViewHolderForAdapterPosition(currentItem) as? WebtoonHolder)?.getScrollY() ?: 0, ) } @@ -168,4 +167,14 @@ class WebtoonReaderFragment : BaseReaderFragment() } return true } + + private fun RecyclerView.findCurrentPagePosition(): Int { + val centerX = width / 2f + val centerY = height - resources.getDimension(R.dimen.webtoon_pages_gap) + if (centerY <= 0) { + return RecyclerView.NO_POSITION + } + val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION + return getChildAdapterPosition(view) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 41257db6f..0caec63bb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.getCauseUrl import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs @@ -72,24 +73,23 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { if (filterCoordinator.isFilterApplied) { filterCoordinator.reset() } else { - openInBrowser() + openInBrowser(null) // should never be called } } override fun onSecondaryErrorActionClick(error: Throwable) { - openInBrowser() + openInBrowser(error.getCauseUrl()) } - private fun openInBrowser() { - val browserUrl = viewModel.browserUrl - if (browserUrl.isNullOrEmpty()) { + private fun openInBrowser(url: String?) { + if (url.isNullOrEmpty()) { Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) .show() } else { startActivity( BrowserActivity.newIntent( requireContext(), - browserUrl, + url, viewModel.source, viewModel.source.getTitle(requireContext()), ), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index c3b948f18..6ee783c4c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -21,10 +21,10 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.getCauseUrl import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.sizeOrZero import org.koitharu.kotatsu.download.ui.worker.DownloadWorker @@ -39,7 +39,6 @@ import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga import javax.inject.Inject @@ -68,9 +67,6 @@ open class RemoteListViewModel @Inject constructor( private var loadingJob: Job? = null private var randomJob: Job? = null - val browserUrl: String? - get() = (repository as? ParserMangaRepository)?.domain?.let { "https://$it" } - override val content = combine( mangaList.map { it?.skipNsfwIfNeeded() }, observeListModeWithTriggers(), @@ -82,7 +78,7 @@ open class RemoteListViewModel @Inject constructor( list.isNullOrEmpty() && error != null -> add( error.toErrorState( canRetry = true, - secondaryAction = if (error !is NotFoundException && browserUrl != null) R.string.open_in_browser else 0, + secondaryAction = if (error.getCauseUrl().isNullOrEmpty()) 0 else R.string.open_in_browser, ), ) @@ -170,7 +166,7 @@ open class RemoteListViewModel @Inject constructor( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, textSecondary = 0, - actionStringRes = if (canResetFilter) R.string.reset_filter else R.string.open_in_browser, + actionStringRes = if (canResetFilter) R.string.reset_filter else 0, ) protected open suspend fun onBuildList(list: MutableList) = Unit diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt index 3f4d6bafa..a10a912f4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt @@ -7,7 +7,10 @@ import android.view.View import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.updatePadding -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt index 68050b01f..c27da88a8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt index b43004a6d..04511f5cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt index 604602ae1..ab78e7d5a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt @@ -11,7 +11,7 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView.NO_ID -import coil.ImageLoader +import coil3.ImageLoader import com.google.android.material.tabs.TabLayout import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index 217cee55a..0542c61d2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -20,7 +20,6 @@ import org.koitharu.kotatsu.core.parser.MangaIntent 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.ifZero import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.requireValue diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt index f8d230fd0..38acf30a2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt index 29d27adf1..2aca8cf01 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt @@ -1,7 +1,8 @@ package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index 975dad0d4..c100efbcc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -11,7 +11,7 @@ import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets import androidx.core.view.updatePadding -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver @@ -26,7 +26,7 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivitySearchBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver +import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration @@ -98,7 +98,8 @@ class SearchActivity : viewModel.list.observe(this, adapter) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.recyclerView)) + + DownloadDialogFragment.registerCallback(this, viewBinding.recyclerView) } override fun onWindowInsetsChanged(insets: Insets) { @@ -184,7 +185,7 @@ class SearchActivity : } R.id.action_save -> { - viewModel.download(collectSelectedItems()) + DownloadDialogFragment.show(supportFragmentManager, collectSelectedItems()) mode?.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index 3edd17e9e..c4cb96024 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -28,10 +28,7 @@ import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.ListMode 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.printStackTraceDebug -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository @@ -55,14 +52,12 @@ class SearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val mangaListMapper: MangaListMapper, private val mangaRepositoryFactory: MangaRepository.Factory, - private val downloadScheduler: DownloadWorker.Scheduler, private val sourcesRepository: MangaSourcesRepository, private val historyRepository: HistoryRepository, private val localMangaRepository: LocalMangaRepository, private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { - val onDownloadStarted = MutableEventFlow() val query = savedStateHandle.get(SearchActivity.EXTRA_QUERY).orEmpty() private val retryCounter = MutableStateFlow(0) @@ -109,13 +104,6 @@ class SearchViewModel @Inject constructor( retryCounter.value += 1 } - fun download(items: Set) { - launchJob(Dispatchers.Default) { - downloadScheduler.schedule(items) - onDownloadStarted.call(Unit) - } - } - @CheckResult private fun searchImpl(q: String): Flow> = channelFlow { searchHistory(q)?.let { send(it) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt index 1d8e62937..d14206a68 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt @@ -3,7 +3,7 @@ package org.koitharu.kotatsu.search.ui.multi.adapter import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView.RecycledViewPool -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt index 64a45b8d8..f7c2c13f4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt @@ -4,7 +4,7 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView.RecycledViewPool -import coil.ImageLoader +import coil3.ImageLoader import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt index 0e9e6721e..49b7d467c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -7,7 +7,7 @@ import androidx.core.graphics.Insets import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.ItemTouchHelper -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.VoiceInputContract @@ -74,6 +74,13 @@ class SearchSuggestionFragment : companion object { + @Deprecated( + "", + ReplaceWith( + "SearchSuggestionFragment()", + "org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment", + ), + ) fun newInstance() = SearchSuggestionFragment() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt index c604a2c11..507d15734 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.sizeOrZero import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import javax.inject.Inject @@ -103,7 +104,7 @@ class SearchSuggestionViewModel @Inject constructor( suggestionJob?.cancel() suggestionJob = combine( query.debounce(DEBOUNCE_TIMEOUT), - sourcesRepository.observeEnabledSources().map { it.toSet() }, + sourcesRepository.observeEnabledSources().map { it.mapToSet { x -> x.name } }, settings.observeAsFlow(AppSettings.KEY_SEARCH_SUGGESTION_TYPES) { searchSuggestionTypes }, ::Triple, ).mapLatest { (searchQuery, enabledSources, types) -> @@ -116,7 +117,7 @@ class SearchSuggestionViewModel @Inject constructor( private suspend fun buildSearchSuggestion( searchQuery: String, - enabledSources: Set, + enabledSources: Set, types: Set, ): List = coroutineScope { val queriesDeferred = if (SearchSuggestionType.QUERIES_RECENT in types) { @@ -169,7 +170,7 @@ class SearchSuggestionViewModel @Inject constructor( if (!mangaList.isNullOrEmpty()) { add(SearchSuggestionItem.MangaList(mangaList)) } - sources?.mapTo(this) { SearchSuggestionItem.Source(it, it in enabledSources) } + sources?.mapTo(this) { SearchSuggestionItem.Source(it, it.name in enabledSources) } queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) } authors?.mapTo(this) { SearchSuggestionItem.Author(it) } hints?.mapTo(this) { SearchSuggestionItem.Hint(it) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt index 9b923e571..a788c6b88 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.search.ui.suggestion.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt index 47415984d..8bb08321b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt @@ -1,7 +1,10 @@ package org.koitharu.kotatsu.search.ui.suggestion.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary @@ -10,8 +13,8 @@ import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.image.AnimatedFaviconDrawable import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceBinding import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem @@ -40,7 +43,7 @@ fun searchSuggestionSourceAD( fallback(fallbackIcon) placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)) error(fallbackIcon) - source(item.source) + mangaSourceExtra(item.source) enqueueWith(coil) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceTipAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceTipAD.kt index 7dad1c854..7d9b07ab1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceTipAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceTipAD.kt @@ -1,7 +1,10 @@ package org.koitharu.kotatsu.search.ui.suggestion.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary @@ -10,8 +13,8 @@ import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.image.AnimatedFaviconDrawable import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceTipBinding import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem @@ -37,7 +40,7 @@ fun searchSuggestionSourceTipAD( fallback(fallbackIcon) placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)) error(fallbackIcon) - source(item.source) + mangaSourceExtra(item.source) enqueueWith(coil) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt index 789704e90..3c58a75ac 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt @@ -4,7 +4,9 @@ import androidx.core.view.updatePadding import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 +import coil3.request.transformations import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding @@ -14,8 +16,8 @@ import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemSearchSuggestionMangaGridBinding import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener @@ -61,7 +63,7 @@ private fun searchSuggestionMangaGridAD( defaultPlaceholders(context) allowRgb565(true) transformations(TrimTransformation()) - source(item.source) + mangaSourceExtra(item.source) enqueueWith(coil) } binding.textViewTitle.text = item.title diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt index 1a2922728..eece51d8f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.DownloadFormat -import org.koitharu.kotatsu.core.prefs.ReaderAnimation +import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.resolveFile @@ -55,6 +55,10 @@ class DownloadsSettingsFragment : entryValues = DownloadFormat.entries.names() setDefaultValueCompat(DownloadFormat.AUTOMATIC.name) } + findPreference(AppSettings.KEY_DOWNLOADS_METERED_NETWORK)?.run { + entryValues = TriStateOption.entries.names() + setDefaultValueCompat(TriStateOption.ASK.name) + } dozeHelper.updatePreference() } @@ -81,7 +85,7 @@ class DownloadsSettingsFragment : findPreference(key)?.bindDirectoriesCount() } - AppSettings.KEY_DOWNLOADS_WIFI -> { + AppSettings.KEY_DOWNLOADS_METERED_NETWORK -> { updateDownloadsConstraints() } @@ -157,12 +161,17 @@ class DownloadsSettingsFragment : } private fun updateDownloadsConstraints() { - val preference = findPreference(AppSettings.KEY_DOWNLOADS_WIFI) + val preference = findPreference(AppSettings.KEY_DOWNLOADS_METERED_NETWORK) viewLifecycleScope.launch { try { preference?.isEnabled = false withContext(Dispatchers.Default) { - downloadsScheduler.updateConstraints() + val option = when (settings.allowDownloadOnMeteredNetwork) { + TriStateOption.ENABLED -> true + TriStateOption.ASK -> return@withContext + TriStateOption.DISABLED -> false + } + downloadsScheduler.updateConstraints(option) } } catch (e: Exception) { e.printStackTraceDebug() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt index 188b9f28c..c0203869f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt @@ -81,7 +81,7 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), } override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { - AppSettings.PROXY_TEST -> { + AppSettings.KEY_PROXY_TEST -> { testConnection() true } @@ -102,13 +102,13 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), findPreference(AppSettings.KEY_PROXY_AUTH)?.isEnabled = isProxyEnabled findPreference(AppSettings.KEY_PROXY_LOGIN)?.isEnabled = isProxyEnabled findPreference(AppSettings.KEY_PROXY_PASSWORD)?.isEnabled = isProxyEnabled - findPreference(AppSettings.PROXY_TEST)?.isEnabled = isProxyEnabled && testJob?.isActive != true + findPreference(AppSettings.KEY_PROXY_TEST)?.isEnabled = isProxyEnabled && testJob?.isActive != true } private fun testConnection() { testJob?.cancel() testJob = viewLifecycleScope.launch { - val pref = findPreference(AppSettings.PROXY_TEST) + val pref = findPreference(AppSettings.KEY_PROXY_TEST) pref?.run { setSummary(R.string.loading_) isEnabled = false diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt index d46843db9..052a1e4c1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings import android.content.Intent import android.content.SharedPreferences +import android.content.pm.ActivityInfo import android.os.Bundle import android.view.View import androidx.preference.ListPreference @@ -38,6 +39,15 @@ class ReaderSettingsFragment : ) setDefaultValueCompat(ReaderMode.STANDARD.name) } + findPreference(AppSettings.KEY_READER_ORIENTATION)?.run { + entryValues = arrayOf( + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED.toString(), + ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR.toString(), + ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT.toString(), + ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE.toString(), + ) + setDefaultValueCompat(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED.toString()) + } findPreference(AppSettings.KEY_READER_BACKGROUND)?.run { entryValues = ReaderBackground.entries.names() setDefaultValueCompat(ReaderBackground.DEFAULT.name) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt index 2f8d648d1..c554de7b1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -6,10 +6,13 @@ import android.net.Uri import android.os.Bundle import android.provider.Settings import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.viewModels import androidx.core.graphics.Insets +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.preference.Preference @@ -22,11 +25,17 @@ import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.settings.about.AboutSettingsFragment +import org.koitharu.kotatsu.settings.search.SettingsItem +import org.koitharu.kotatsu.settings.search.SettingsSearchFragment +import org.koitharu.kotatsu.settings.search.SettingsSearchMenuProvider +import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment @@ -47,6 +56,8 @@ class SettingsActivity : private var screenPadding = 0 + private val viewModel: SettingsSearchViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySettingsBinding.inflate(layoutInflater)) @@ -63,6 +74,9 @@ class SettingsActivity : replace(R.id.container_master, RootSettingsFragment()) } } + viewModel.isSearchActive.observe(this, ::toggleSearchMode) + viewModel.onNavigateToPreference.observeEvent(this, ::navigateToPreference) + addMenuProvider(SettingsSearchMenuProvider(viewModel)) addMenuProvider(SettingsMenuProvider(this)) } @@ -70,10 +84,12 @@ class SettingsActivity : caller: PreferenceFragmentCompat, pref: Preference, ): Boolean { - val fm = supportFragmentManager - val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false) - fragment.arguments = pref.extras - openFragment(fragment, isFromRoot = caller is RootSettingsFragment) + val fragmentName = pref.fragment ?: return false + openFragment( + fragmentClass = FragmentFactory.loadFragmentClass(classLoader, fragmentName), + args = pref.peekExtras(), + isFromRoot = caller is RootSettingsFragment, + ) return true } @@ -93,11 +109,12 @@ class SettingsActivity : } ?: setTitle(title ?: getString(R.string.settings)) } - fun openFragment(fragment: Fragment, isFromRoot: Boolean) { + fun openFragment(fragmentClass: Class, args: Bundle?, isFromRoot: Boolean) { + viewModel.discardSearch() val hasFragment = supportFragmentManager.findFragmentById(R.id.container) != null supportFragmentManager.commit { setReorderingAllowed(true) - replace(R.id.container, fragment) + replace(R.id.container, fragmentClass, args) setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) if (!isMasterDetails || (hasFragment && !isFromRoot)) { addToBackStack(null) @@ -105,6 +122,27 @@ class SettingsActivity : } } + private fun toggleSearchMode(isEnabled: Boolean) { + viewBinding.containerSearch.isVisible = isEnabled + val searchFragment = supportFragmentManager.findFragmentById(R.id.container_search) + if (searchFragment != null) { + if (!isEnabled) { + invalidateOptionsMenu() + supportFragmentManager.commit { + setReorderingAllowed(true) + remove(searchFragment) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_CLOSE) + } + } + } else if (isEnabled) { + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.container_search, SettingsSearchFragment::class.java, null) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + } + } + } + private fun openDefaultFragment() { val fragment = when (intent?.action) { ACTION_READER -> ReaderSettingsFragment() @@ -135,6 +173,12 @@ class SettingsActivity : } } + private fun navigateToPreference(item: SettingsItem) { + val args = Bundle(1) + args.putString(ARG_PREF_KEY, item.key) + openFragment(item.fragmentClass, args, true) + } + companion object { private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" @@ -149,6 +193,7 @@ class SettingsActivity : private const val EXTRA_SOURCE = "source" private const val HOST_ABOUT = "about" private const val HOST_SYNC_SETTINGS = "sync-settings" + const val ARG_PREF_KEY = "pref_key" fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt index cc6655248..5419fed7a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt @@ -32,6 +32,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { isEnabled = viewModel.isUpdateSupported } findPreference(AppSettings.KEY_UPDATES_UNSTABLE)?.run { + isVisible = viewModel.isUpdateSupported isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable if (!isEnabled) isChecked = true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt index 919728c4d..61b64637a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt @@ -15,7 +15,6 @@ import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import java.io.File import java.io.FileDescriptor import java.io.FileInputStream @@ -69,7 +68,7 @@ class AppBackupAgent : BackupAgent() { @VisibleForTesting fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking { - BackupZipOutput(context).use { backup -> + BackupZipOutput.createTemp(context).use { backup -> backup.put(repository.createIndex()) backup.put(repository.dumpHistory()) backup.put(repository.dumpCategories()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt index 654e1656b..62e21aa00 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -23,7 +23,7 @@ class BackupViewModel @Inject constructor( init { launchLoadingJob { - val file = BackupZipOutput(context).use { backup -> + val file = BackupZipOutput.createTemp(context).use { backup -> val step = 1f / 6f backup.put(repository.createIndex()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt new file mode 100644 index 000000000..81602e128 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.settings.backup + +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.core.backup.BackupZipOutput +import org.koitharu.kotatsu.core.backup.ExternalBackupStorage +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.CoroutineIntentService +import javax.inject.Inject + +@AndroidEntryPoint +class PeriodicalBackupService : CoroutineIntentService() { + + @Inject + lateinit var externalBackupStorage: ExternalBackupStorage + + @Inject + lateinit var repository: BackupRepository + + @Inject + lateinit var settings: AppSettings + + override suspend fun IntentJobContext.processIntent(intent: Intent) { + if (!settings.isPeriodicalBackupEnabled || settings.periodicalBackupDirectory == null) { + return + } + val lastBackupDate = externalBackupStorage.getLastBackupDate() + if (lastBackupDate != null && lastBackupDate.time + settings.periodicalBackupFrequency > System.currentTimeMillis()) { + return + } + val output = BackupZipOutput.createTemp(applicationContext) + try { + output.use { backup -> + backup.put(repository.createIndex()) + backup.put(repository.dumpHistory()) + backup.put(repository.dumpCategories()) + backup.put(repository.dumpFavourites()) + backup.put(repository.dumpBookmarks()) + backup.put(repository.dumpSources()) + backup.put(repository.dumpSettings()) + backup.finish() + } + externalBackupStorage.put(output.file) + externalBackupStorage.trim(settings.periodicalBackupMaxCount) + } finally { + output.file.delete() + } + } + + override fun IntentJobContext.onError(error: Throwable) = Unit +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt index 17ded15be..d7bee53d3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import android.text.format.DateUtils import android.view.View import android.widget.Toast import androidx.activity.result.ActivityResultCallback @@ -15,7 +16,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.backup.DIR_BACKUPS +import org.koitharu.kotatsu.core.backup.BackupZipOutput.Companion.DIR_BACKUPS +import org.koitharu.kotatsu.core.backup.ExternalBackupStorage import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.resolveFile @@ -36,7 +38,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi ActivityResultCallback { @Inject - lateinit var scheduler: PeriodicalBackupWorker.Scheduler + lateinit var backupStorage: ExternalBackupStorage private val outputSelectCall = registerForActivityResult( ActivityResultContracts.OpenDocumentTree(), @@ -155,8 +157,9 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi if (result != null) { val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION context?.contentResolver?.takePersistableUriPermission(result, takeFlags) - settings.periodicalBackupOutput = result + settings.periodicalBackupDirectory = result bindOutputSummary() + bindLastBackupInfo() } } @@ -164,7 +167,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return viewLifecycleScope.launch { preference.summary = withContext(Dispatchers.Default) { - val value = settings.periodicalBackupOutput + val value = settings.periodicalBackupDirectory value?.toUserFriendlyString(preference.context) ?: preference.context.run { getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) }.path @@ -176,11 +179,13 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return viewLifecycleScope.launch { val lastDate = withContext(Dispatchers.Default) { - scheduler.getLastSuccessfulBackup() + backupStorage.getLastBackupDate() } preference.summary = lastDate?.let { - val format = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.MEDIUM, SimpleDateFormat.SHORT) - preference.context.getString(R.string.last_successful_backup, format.format(it)) + preference.context.getString( + R.string.last_successful_backup, + DateUtils.getRelativeTimeSpanString(it.time), + ) } preference.isVisible = lastDate != null } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index 9acb88717..3af75946f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -1,8 +1,6 @@ package org.koitharu.kotatsu.settings.backup -import android.content.ContentResolver import android.content.Context -import android.net.Uri import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -13,11 +11,10 @@ import org.koitharu.kotatsu.core.backup.BackupEntry import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipInput import org.koitharu.kotatsu.core.backup.CompositeResult -import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException -import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException 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.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.parsers.util.SuspendLazy import java.io.File @@ -75,7 +72,11 @@ class RestoreViewModel @Inject constructor( override fun onCleared() { super.onCleared() - backupInput.peek()?.cleanupAsync() + runCatching { + backupInput.peek()?.closeAndDelete() + }.onFailure { + it.printStackTraceDebug() + } } fun onItemClick(item: BackupEntryModel) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItem.kt new file mode 100644 index 000000000..f10c342aa --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItem.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.settings.search + +import androidx.preference.PreferenceFragmentCompat +import org.koitharu.kotatsu.list.ui.model.ListModel + +data class SettingsItem( + val key: String, + val title: CharSequence, + val breadcrumbs: List, + val fragmentClass: Class, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is SettingsItem && other.key == key + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItemAD.kt new file mode 100644 index 000000000..5a35015c3 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItemAD.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.settings.search + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.textAndVisible +import org.koitharu.kotatsu.databinding.ItemPreferenceBinding + +fun settingsItemAD( + listener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemPreferenceBinding.inflate(layoutInflater, parent, false) }, +) { + + AdapterDelegateClickListenerAdapter(this, listener).attach() + val breadcrumbsSeparator = getString(R.string.breadcrumbs_separator) + + bind { + binding.textViewTitle.text = item.title + binding.textViewSummary.textAndVisible = item.breadcrumbs.joinToString(breadcrumbsSeparator) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchFragment.kt new file mode 100644 index 000000000..4b11f6d5d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchFragment.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.settings.search + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.graphics.Insets +import androidx.core.view.updatePadding +import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.BaseFragment +import org.koitharu.kotatsu.core.ui.BaseListAdapter +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding +import org.koitharu.kotatsu.list.ui.adapter.ListItemType + +@AndroidEntryPoint +class SettingsSearchFragment : BaseFragment(), OnListItemClickListener { + + private val viewModel: SettingsSearchViewModel by activityViewModels() + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSearchSuggestionBinding { + return FragmentSearchSuggestionBinding.inflate(inflater, container, false) + } + + override fun onViewBindingCreated(binding: FragmentSearchSuggestionBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + val adapter = BaseListAdapter() + .addDelegate(ListItemType.NAV_ITEM, settingsItemAD(this)) + binding.root.adapter = adapter + binding.root.setHasFixedSize(true) + viewModel.content.observe(viewLifecycleOwner, adapter) + } + + override fun onWindowInsetsChanged(insets: Insets) { + val extraPadding = resources.getDimensionPixelOffset(R.dimen.list_spacing) + requireViewBinding().root.updatePadding( + top = extraPadding, + right = insets.right, + left = insets.left, + bottom = insets.bottom, + ) + } + + override fun onItemClick(item: SettingsItem, view: View) = viewModel.navigateToPreference(item) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt new file mode 100644 index 000000000..2bdc6871b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt @@ -0,0 +1,84 @@ +package org.koitharu.kotatsu.settings.search + +import android.annotation.SuppressLint +import android.content.Context +import androidx.annotation.XmlRes +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceManager +import androidx.preference.PreferenceScreen +import androidx.preference.get +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.settings.AppearanceSettingsFragment +import org.koitharu.kotatsu.settings.DownloadsSettingsFragment +import org.koitharu.kotatsu.settings.NetworkSettingsFragment +import org.koitharu.kotatsu.settings.ReaderSettingsFragment +import org.koitharu.kotatsu.settings.ServicesSettingsFragment +import org.koitharu.kotatsu.settings.about.AboutSettingsFragment +import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment +import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment +import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment +import javax.inject.Inject + +@Reusable +@SuppressLint("RestrictedApi") +class SettingsSearchHelper @Inject constructor( + @ApplicationContext private val context: Context, +) { + + fun inflatePreferences(): List { + val preferenceManager = PreferenceManager(context) + val result = ArrayList() + preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_sources, emptyList(), SourcesSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_network, emptyList(), NetworkSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_user_data, emptyList(), UserDataSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java) + return result + } + + private fun PreferenceManager.inflateTo( + result: MutableList, + @XmlRes resId: Int, + breadcrumbs: List, + fragmentClass: Class + ) { + val screen = inflateFromResource(context, resId, null) + val screenTitle = screen.title?.toString() + screen.inflateTo( + result = result, + breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, + fragmentClass = fragmentClass, + ) + } + + private fun PreferenceScreen.inflateTo( + result: MutableList, + breadcrumbs: List, + fragmentClass: Class + ): Unit = repeat(preferenceCount) { i -> + val pref = this[i] + if (pref is PreferenceScreen) { + val screenTitle = pref.title?.toString() + pref.inflateTo( + result = result, + breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, + fragmentClass = fragmentClass, + ) + } else { + result.add( + SettingsItem( + key = pref.key ?: return@repeat, + title = pref.title ?: return@repeat, + breadcrumbs = breadcrumbs, + fragmentClass = fragmentClass, + ), + ) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchMenuProvider.kt new file mode 100644 index 000000000..7f2f4527e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchMenuProvider.kt @@ -0,0 +1,51 @@ +package org.koitharu.kotatsu.settings.search + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.core.view.MenuProvider +import org.koitharu.kotatsu.R + +class SettingsSearchMenuProvider( + private val viewModel: SettingsSearchViewModel, +) : MenuProvider, MenuItem.OnActionExpandListener, SearchView.OnQueryTextListener { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_search, menu) + val menuItem = menu.findItem(R.id.action_search) + menuItem.setOnActionExpandListener(this) + val searchView = menuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.queryHint = menuItem.title + } + + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + val currentQuery = viewModel.currentQuery + if (currentQuery.isNotEmpty()) { + val menuItem = menu.findItem(R.id.action_search) + menuItem.expandActionView() + val searchView = menuItem.actionView as SearchView + searchView.setQuery(currentQuery, false) + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false + + override fun onMenuItemActionExpand(item: MenuItem): Boolean = true + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + viewModel.discardSearch() + return true + } + + override fun onQueryTextSubmit(query: String?): Boolean { + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.onQueryChanged(newText.orEmpty()) + return true + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchViewModel.kt new file mode 100644 index 000000000..6be04636a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchViewModel.kt @@ -0,0 +1,47 @@ +package org.koitharu.kotatsu.settings.search + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import javax.inject.Inject + +@HiltViewModel +class SettingsSearchViewModel @Inject constructor( + private val searchHelper: SettingsSearchHelper, +) : BaseViewModel() { + + private val query = MutableStateFlow("") + private val allSettings by lazy { + searchHelper.inflatePreferences() + } + + val content = query.map { q -> + allSettings.filter { it.title.contains(q, ignoreCase = true) } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) + + val isSearchActive = query.map { + it.isNotEmpty() + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) + + val onNavigateToPreference = MutableEventFlow() + val currentQuery: String + get() = query.value + + fun onQueryChanged(value: String) { + query.value = value + } + + fun discardSearch() = onQueryChanged("") + + fun navigateToPreference(item: SettingsItem) { + onNavigateToPreference.call(item) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt index ba9645bf6..88d62e1b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt @@ -7,6 +7,7 @@ import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.parser.EmptyMangaRepository @@ -74,6 +75,12 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc viewModel.isEnabled.observe(viewLifecycleOwner) { enabled -> findPreference(KEY_ENABLE)?.isChecked = enabled } + viewModel.browserUrl.observe(viewLifecycleOwner) { + findPreference(AppSettings.KEY_OPEN_BROWSER)?.run { + isVisible = it != null + summary = it + } + } viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) } @@ -84,6 +91,18 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc true } + AppSettings.KEY_OPEN_BROWSER -> { + startActivity( + BrowserActivity.newIntent( + context = preference.context, + url = viewModel.browserUrl.value ?: return false, + source = viewModel.source, + title = viewModel.source.getTitle(preference.context), + ), + ) + true + } + AppSettings.KEY_COOKIES_CLEAR -> { viewModel.clearCookies() true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt index b241030a0..6d23b430d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt @@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction @@ -36,12 +37,14 @@ class SourceSettingsViewModel @Inject constructor( val onActionDone = MutableEventFlow() val username = MutableStateFlow(null) + val browserUrl = MutableStateFlow(null) val isEnabled = mangaSourcesRepository.observeIsEnabled(source) private var usernameLoadJob: Job? = null init { when (repository) { is ParserMangaRepository -> { + browserUrl.value = "https://${repository.domain}" repository.getConfig().subscribe(this) loadUsername(repository.getAuthProvider()) } @@ -58,11 +61,14 @@ class SourceSettingsViewModel @Inject constructor( } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - when (repository) { - is CachingMangaRepository -> { - if (key != SourceSettings.KEY_SLOWDOWN && key != SourceSettings.KEY_SORT_ORDER) { - repository.invalidateCache() - } + if (repository is CachingMangaRepository) { + if (key != SourceSettings.KEY_SLOWDOWN && key != SourceSettings.KEY_SORT_ORDER) { + repository.invalidateCache() + } + } + if (repository is ParserMangaRepository) { + if (key == AppSettings.KEY_OPEN_BROWSER) { + browserUrl.value = "https://${repository.domain}" } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt index a27037fa4..ae2ccf073 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.settings.sources.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.ReorderableListAdapter import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt index ca269f4a0..9d705714d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -7,7 +7,10 @@ import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R @@ -20,8 +23,8 @@ import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding import org.koitharu.kotatsu.databinding.ItemTipBinding import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem @@ -65,7 +68,7 @@ fun sourceConfigItemDelegate2( error(fallbackIcon) placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)) fallback(fallbackIcon) - source(item.source) + mangaSourceExtra(item.source) enqueueWith(coil) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt index 035437529..09fb7ab92 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt @@ -2,8 +2,12 @@ package org.koitharu.kotatsu.settings.sources.catalog import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.core.view.updatePaddingRelative import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier.Companion.ignoreCaptchaErrors @@ -16,12 +20,14 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelOffset +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding import org.koitharu.kotatsu.list.ui.model.ListModel +import com.google.android.material.R as materialR fun sourceCatalogItemSourceAD( coil: ImageLoader, @@ -39,6 +45,13 @@ fun sourceCatalogItemSourceAD( binding.root.setOnClickListener { v -> listener.onItemClick(item, v) } + val basePadding = context.getThemeDimensionPixelOffset( + materialR.attr.listPreferredItemPaddingEnd, + binding.root.paddingStart, + ) + binding.root.updatePaddingRelative( + end = (basePadding - context.resources.getDimensionPixelOffset(R.dimen.margin_small)).coerceAtLeast(0), + ) bind { binding.textViewTitle.text = item.source.getTitle(context) @@ -54,7 +67,7 @@ fun sourceCatalogItemSourceAD( error(fallbackIcon) placeholder(AnimatedFaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)) fallback(fallbackIcon) - source(item.source) + mangaSourceExtra(item.source) ignoreCaptchaErrors() enqueueWith(coil) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt index 543a4e707..bb4b71200 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt @@ -9,7 +9,7 @@ import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets import androidx.core.view.updatePadding -import coil.ImageLoader +import coil3.ImageLoader import com.google.android.material.appbar.AppBarLayout import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogAdapter.kt index 0e2ebbf01..bb2e411e5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogAdapter.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.settings.sources.catalog import android.content.Context import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt index 85b06587b..7df5129f6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt @@ -18,10 +18,13 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.TABLE_SOURCES import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.model.unwrap import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.SourcesSortOrder +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import javax.inject.Inject @@ -63,8 +66,8 @@ class SourcesListProducer @Inject constructor( } private suspend fun buildList(): List { - val enabledSources = repository.getEnabledSources() - val pinned = repository.getPinnedSources() + val enabledSources = repository.getEnabledSources().filter { it.unwrap() is MangaParserSource } + val pinned = repository.getPinnedSources().mapToSet { it.name } val isNsfwDisabled = settings.isNsfwContentDisabled val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER) @@ -79,7 +82,7 @@ class SourcesListProducer @Inject constructor( isEnabled = it in enabledSet, isDraggable = false, isAvailable = !isNsfwDisabled || !it.isNsfw(), - isPinned = it in pinned, + isPinned = it.name in pinned, ) }.ifEmpty { listOf(SourceConfigItem.EmptySearchResult) @@ -100,7 +103,7 @@ class SourcesListProducer @Inject constructor( isEnabled = true, isDraggable = isReorderAvailable, isAvailable = false, - isPinned = it in pinned, + isPinned = it.name in pinned, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt index b970465cc..07bcc8921 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt @@ -14,7 +14,7 @@ import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R @@ -106,8 +106,11 @@ class SourcesManageFragment : } override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) { - val fragment = SourceSettingsFragment.newInstance(item.source) - (activity as? SettingsActivity)?.openFragment(fragment, false) + (activity as? SettingsActivity)?.openFragment( + fragmentClass = SourceSettingsFragment::class.java, + args = Bundle(1).apply { putString(SourceSettingsFragment.EXTRA_SOURCE, item.source.name) }, + isFromRoot = false, + ) } override fun onItemLiftClick(item: SourceConfigItem.SourceItem) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt index 28fbf3aca..8bc7d73bf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt @@ -50,6 +50,9 @@ class SliderPreference @JvmOverloads constructor( valueTo = getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt() stepSize = getFloat(R.styleable.SliderPreference_android_stepSize, stepSize.toFloat()).toInt() isTickVisible = getBoolean(R.styleable.SliderPreference_tickVisible, isTickVisible) + if (getBoolean(R.styleable.SliderPreference_useSimpleSummaryProvider, false)) { + summaryProvider = SimpleSummaryProvider + } } } @@ -118,6 +121,11 @@ class SliderPreference @JvmOverloads constructor( } } + private object SimpleSummaryProvider : SummaryProvider { + + override fun provideSummary(preference: SliderPreference) = preference.value.toString() + } + private class SavedState : AbsSavedState { val valueFrom: Int diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt index 183eefeb6..794e96401 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.settings.backup.PeriodicalBackupWorker import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.tracker.work.TrackWorker import javax.inject.Inject @@ -16,7 +15,6 @@ class WorkScheduleManager @Inject constructor( private val settings: AppSettings, private val suggestionScheduler: SuggestionsWorker.Scheduler, private val trackerScheduler: TrackWorker.Scheduler, - private val periodicalBackupScheduler: PeriodicalBackupWorker.Scheduler, ) : SharedPreferences.OnSharedPreferenceChangeListener { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { @@ -35,13 +33,6 @@ class WorkScheduleManager @Inject constructor( isEnabled = settings.isSuggestionsEnabled, force = key != AppSettings.KEY_SUGGESTIONS, ) - - AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, - AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY -> updateWorker( - scheduler = periodicalBackupScheduler, - isEnabled = settings.isPeriodicalBackupEnabled, - force = key != AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, - ) } } @@ -50,7 +41,6 @@ class WorkScheduleManager @Inject constructor( processLifecycleScope.launch(Dispatchers.Default) { updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, true) // always force due to adaptive interval updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false) - updateWorkerImpl(periodicalBackupScheduler, settings.isPeriodicalBackupEnabled, false) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt index 0889b3f53..9ebef64a7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt @@ -3,7 +3,6 @@ package org.koitharu.kotatsu.stats.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey -import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.history.data.HistoryEntity @Entity( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt index 7facb67e0..9b621e1b0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt @@ -1,10 +1,7 @@ package org.koitharu.kotatsu.stats.data -import androidx.collection.LongIntMap -import androidx.collection.MutableLongIntMap import androidx.room.withTransaction import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -13,7 +10,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.util.ext.combine import org.koitharu.kotatsu.stats.domain.StatsPeriod import org.koitharu.kotatsu.stats.domain.StatsRecord import java.util.NavigableMap diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt index 190f8efe1..7e4c9e739 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsRecord.kt @@ -1,17 +1,9 @@ package org.koitharu.kotatsu.stats.domain -import android.content.Context -import androidx.annotation.ColorInt -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils -import com.google.android.material.R -import com.google.android.material.color.MaterialColors -import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.details.data.ReadingTime import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga import java.util.concurrent.TimeUnit -import kotlin.math.absoluteValue data class StatsRecord( val manga: Manga?, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt index fff0a64f0..141e9e487 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt @@ -12,7 +12,7 @@ import androidx.core.graphics.Insets import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.AsyncListDiffer -import coil.ImageLoader +import coil3.ImageLoader import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import dagger.hilt.android.AndroidEntryPoint diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt index 40b07d252..f6a309b22 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsViewModel.kt @@ -21,7 +21,7 @@ import javax.inject.Inject @HiltViewModel class StatsViewModel @Inject constructor( private val repository: StatsRepository, - private val favouritesRepository: FavouritesRepository, + favouritesRepository: FavouritesRepository, ) : BaseViewModel() { val period = MutableStateFlow(StatsPeriod.WEEK) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt index f81b4a6bd..9fc1f782b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt @@ -1,10 +1,8 @@ package org.koitharu.kotatsu.stats.ui.sheet import androidx.collection.IntList -import androidx.collection.LongIntMap import androidx.collection.MutableIntList import androidx.collection.emptyIntList -import androidx.collection.emptyLongIntMap import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -15,7 +13,6 @@ import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.stats.data.StatsRepository -import org.koitharu.kotatsu.stats.domain.StatsRecord import java.time.Instant import java.util.concurrent.TimeUnit import javax.inject.Inject diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt index 6f2cc29cb..178e25f18 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/views/PieChartView.kt @@ -13,6 +13,7 @@ import androidx.core.graphics.ColorUtils import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.parsers.util.replaceWith +import kotlin.math.atan2 import kotlin.math.sqrt class PieChartView @JvmOverloads constructor( @@ -130,7 +131,7 @@ class PieChartView @JvmOverloads constructor( if (distance < chartBounds.height() / 4f || distance > chartBounds.centerX()) { return -1 } - var touchAngle = Math.toDegrees(Math.atan2(dy, dx)).toFloat() + var touchAngle = Math.toDegrees(atan2(dy, dx)).toFloat() if (touchAngle < 0) { touchAngle += 360 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 172db74e7..d7bfd3b67 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -31,8 +31,8 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.await import androidx.work.workDataOf -import coil.ImageLoader -import coil.request.ImageRequest +import coil3.ImageLoader +import coil3.request.ImageRequest import dagger.Reusable import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -57,6 +57,7 @@ import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.flatten +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.sizeOrZero @@ -257,7 +258,7 @@ class SuggestionsWorker @AssistedInject constructor( val list = repository.getList( offset = 0, order = order, - filter = MangaListFilter(tags = setOfNotNull(tag)) + filter = MangaListFilter(tags = setOfNotNull(tag)), ).asArrayList() if (appSettings.isSuggestionsExcludeNsfw) { list.removeAll { it.isNsfw } @@ -296,7 +297,7 @@ class SuggestionsWorker @AssistedInject constructor( coil.execute( ImageRequest.Builder(applicationContext) .data(manga.coverUrl) - .tag(manga.source) + .mangaSourceExtra(manga.source) .build(), ).toBitmapOrNull(), ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt index 0a27f1ca1..26e3833a7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.network.BaseHttpClient import org.koitharu.kotatsu.core.util.ext.toRequestBody import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.parseJson +import org.koitharu.kotatsu.parsers.util.parseRaw import org.koitharu.kotatsu.parsers.util.removeSurrounding import javax.inject.Inject @@ -30,7 +31,7 @@ class SyncAuthApi @Inject constructor( return response.parseJson().getString("token") } else { val code = response.code - val message = response.use { checkNotNull(it.body).string() }.removeSurrounding('"') + val message = response.parseRaw().removeSurrounding('"') throw SyncApiException(message, code) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt index e686e1349..9a0651b44 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt @@ -4,7 +4,6 @@ import android.accounts.Account import android.accounts.AccountManager import android.content.Context import androidx.annotation.WorkerThread -import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty @@ -30,11 +29,11 @@ class SyncSettings( @set:WorkerThread var syncURL: String get() = account?.let { - val sync_url = accountManager.getUserData(it, KEY_SYNC_URL) - if ( !sync_url.startsWith("http://") && !sync_url.startsWith("https://") ) { - return "http://$sync_url" + val result = accountManager.getUserData(it, KEY_SYNC_URL) + if (!result.startsWith("http://") && !result.startsWith("https://")) { + return "http://$result" } - return sync_url + return result }.ifNullOrEmpty { defaultSyncUrl } set(value) { account?.let { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/CheckNewChaptersUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/CheckNewChaptersUseCase.kt index 886e90a47..6802672d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/CheckNewChaptersUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/CheckNewChaptersUseCase.kt @@ -1,6 +1,6 @@ package org.koitharu.kotatsu.tracker.domain -import coil.request.CachePolicy +import coil3.request.CachePolicy import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.parser.CachingMangaRepository diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugAD.kt index e1a62459a..9a9c589aa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackDebugAD.kt @@ -7,7 +7,8 @@ import androidx.core.text.bold import androidx.core.text.buildSpannedString import androidx.core.text.color import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener @@ -15,8 +16,8 @@ import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemTrackDebugBinding import org.koitharu.kotatsu.tracker.data.TrackEntity import com.google.android.material.R as materialR @@ -38,22 +39,22 @@ fun trackDebugAD( binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run { defaultPlaceholders(context) allowRgb565(true) - source(item.manga.source) + mangaSourceExtra(item.manga.source) enqueueWith(coil) } binding.textViewTitle.text = item.manga.title binding.textViewSummary.text = buildSpannedString { - item.lastCheckTime?.let { - append( + append( + item.lastCheckTime?.let { DateUtils.getRelativeDateTimeString( context, it.toEpochMilli(), DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0, - ), - ) - } + ) + } ?: getString(R.string.never), + ) if (item.lastResult == TrackEntity.RESULT_FAILED) { append(" - ") bold { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt index 517008984..18d02dd3e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt @@ -5,7 +5,7 @@ import android.view.View import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.updatePadding -import coil.ImageLoader +import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseListAdapter diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt index cf591c685..b9b8866ec 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt @@ -16,7 +16,7 @@ import javax.inject.Inject @HiltViewModel class TrackerDebugViewModel @Inject constructor( - private val db: MangaDatabase + db: MangaDatabase ) : BaseViewModel() { val content = db.getTracksDao().observeAll() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt index 9ac349c60..6fb310599 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt @@ -10,7 +10,7 @@ import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import coil.ImageLoader +import coil3.ImageLoader import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.drop diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt index 4e212ad1f..d6c4951ff 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.tracker.ui.feed.adapter import android.content.Context import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt index e69ceb513..0f67d0f1f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt @@ -2,15 +2,16 @@ package org.koitharu.kotatsu.tracker.ui.feed.adapter import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader +import coil3.request.allowRgb565 import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemFeedBinding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem @@ -32,7 +33,7 @@ fun feedItemAD( binding.imageViewCover.newImageRequest(lifecycleOwner, item.imageUrl)?.run { defaultPlaceholders(context) allowRgb565(true) - source(item.manga.source) + mangaSourceExtra(item.manga.source) enqueueWith(coil) } binding.textViewTitle.text = item.title diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/UpdatedMangaAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/UpdatedMangaAD.kt index cb30a3c1f..4d6db468d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/UpdatedMangaAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/UpdatedMangaAD.kt @@ -1,7 +1,7 @@ package org.koitharu.kotatsu.tracker.ui.feed.adapter import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader +import coil3.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseListAdapter diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 0c2cc96c8..f8fe0f4f3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -45,16 +45,18 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy +import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.onEachIndexed import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.trySetForeground +import org.koitharu.kotatsu.download.ui.worker.DownloadTask import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.settings.SettingsActivity @@ -251,11 +253,16 @@ class TrackWorker @AssistedInject constructor( TrackerDownloadStrategy.DOWNLOADED -> { val localManga = localRepositoryLazy.get().findSavedManga(mangaUpdates.manga) if (localManga != null) { - downloadSchedulerLazy.get().schedule( - manga = mangaUpdates.manga, - chaptersIds = mangaUpdates.newChapters.mapToSet { it.id }, - isSilent = true, + val task = DownloadTask( + mangaId = mangaUpdates.manga.id, + isPaused = false, + isSilent = false, + chaptersIds = mangaUpdates.newChapters.ids().toLongArray(), + destination = null, + format = null, + allowMeteredNetwork = settings.allowDownloadOnMeteredNetwork != TriStateOption.DISABLED, ) + downloadSchedulerLazy.get().schedule(setOf(task)) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt index 38b952072..dc18149a5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt @@ -12,12 +12,13 @@ import androidx.core.app.NotificationCompat.VISIBILITY_SECRET import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat -import coil.ImageLoader -import coil.request.ImageRequest +import coil3.ImageLoader +import coil3.request.ImageRequest import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.parsers.model.Manga @@ -67,7 +68,7 @@ class TrackerNotificationHelper @Inject constructor( coil.execute( ImageRequest.Builder(applicationContext) .data(manga.coverUrl) - .tag(manga.source) + .mangaSourceExtra(manga.source) .build(), ).toBitmapOrNull(), ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt index 30d28b251..a4e047999 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt @@ -5,20 +5,23 @@ import android.content.Intent import android.widget.RemoteViews import android.widget.RemoteViewsService import androidx.core.graphics.drawable.toBitmap -import coil.ImageLoader -import coil.executeBlocking -import coil.request.ImageRequest -import coil.size.Size -import coil.transform.RoundedCornersTransformation +import coil3.ImageLoader +import coil3.executeBlocking +import coil3.request.ImageRequest +import coil3.request.transformations +import coil3.size.Size +import coil3.transform.RoundedCornersTransformation import dagger.Lazy import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow +import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.replaceWith +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable class RecentListFactory( private val context: Context, @@ -56,13 +59,12 @@ class RecentListFactory( override fun getViewAt(position: Int): RemoteViews { val views = RemoteViews(context.packageName, R.layout.item_recent) val item = dataSet.getOrNull(position) ?: return views - runCatching { + runCatchingCancellable { coilLazy.get().executeBlocking( ImageRequest.Builder(context) .data(item.coverUrl) .size(coverSize) - .tag(item.source) - .tag(item) + .mangaExtra(item) .transformations(transformation) .build(), ).getDrawableOrThrow().toBitmap() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt index 3009db546..3f6455242 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.widget.recent import android.content.Intent import android.widget.RemoteViewsService -import coil.ImageLoader +import coil3.ImageLoader import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.prefs.AppSettings diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt index 3c74a4428..26d4cee95 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt @@ -5,11 +5,12 @@ import android.content.Intent import android.widget.RemoteViews import android.widget.RemoteViewsService import androidx.core.graphics.drawable.toBitmap -import coil.ImageLoader -import coil.executeBlocking -import coil.request.ImageRequest -import coil.size.Size -import coil.transform.RoundedCornersTransformation +import coil3.ImageLoader +import coil3.executeBlocking +import coil3.request.ImageRequest +import coil3.request.transformations +import coil3.size.Size +import coil3.transform.RoundedCornersTransformation import dagger.Lazy import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R @@ -18,6 +19,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow +import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.replaceWith @@ -73,8 +75,7 @@ class ShelfListFactory( ImageRequest.Builder(context) .data(item.coverUrl) .size(coverSize) - .tag(item.source) - .tag(item) + .mangaExtra(item) .transformations(transformation, TrimTransformation()) .build(), ).getDrawableOrThrow().toBitmap() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt index b8fdffea3..9f9052f12 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt @@ -3,7 +3,7 @@ package org.koitharu.kotatsu.widget.shelf import android.appwidget.AppWidgetManager import android.content.Intent import android.widget.RemoteViewsService -import coil.ImageLoader +import coil3.ImageLoader import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.prefs.AppSettings diff --git a/app/src/main/res/color/ripple_toolbar.xml b/app/src/main/res/color/ripple_toolbar.xml deleted file mode 100644 index 979d0f94a..000000000 --- a/app/src/main/res/color/ripple_toolbar.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/bg_card_bottom.xml b/app/src/main/res/drawable/bg_card_bottom.xml deleted file mode 100644 index f693f7c2a..000000000 --- a/app/src/main/res/drawable/bg_card_bottom.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_card_full.xml b/app/src/main/res/drawable/bg_card_full.xml deleted file mode 100644 index 316531284..000000000 --- a/app/src/main/res/drawable/bg_card_full.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_card_none.xml b/app/src/main/res/drawable/bg_card_none.xml deleted file mode 100644 index 2e6a50b46..000000000 --- a/app/src/main/res/drawable/bg_card_none.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_card_top.xml b/app/src/main/res/drawable/bg_card_top.xml deleted file mode 100644 index 145b73611..000000000 --- a/app/src/main/res/drawable/bg_card_top.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_discord.xml b/app/src/main/res/drawable/ic_discord.xml deleted file mode 100644 index 73fde1989..000000000 --- a/app/src/main/res/drawable/ic_discord.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml deleted file mode 100644 index 55a2da656..000000000 --- a/app/src/main/res/drawable/ic_github.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_list_sheet.xml b/app/src/main/res/drawable/ic_network_cellular.xml similarity index 53% rename from app/src/main/res/drawable/ic_list_sheet.xml rename to app/src/main/res/drawable/ic_network_cellular.xml index 2a7ccb3a9..45146079b 100644 --- a/app/src/main/res/drawable/ic_list_sheet.xml +++ b/app/src/main/res/drawable/ic_network_cellular.xml @@ -7,6 +7,6 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:fillColor="#000" + android:pathData="M19,17H21V9H19M19,21H21V19H19M1,21H17V7H21V1" /> diff --git a/app/src/main/res/drawable/ic_telegram.xml b/app/src/main/res/drawable/ic_telegram.xml deleted file mode 100644 index c18d3a442..000000000 --- a/app/src/main/res/drawable/ic_telegram.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/layout-w600dp-land/activity_details.xml b/app/src/main/res/layout-w600dp-land/activity_details.xml index e3e6fab06..f9fb29423 100644 --- a/app/src/main/res/layout-w600dp-land/activity_details.xml +++ b/app/src/main/res/layout-w600dp-land/activity_details.xml @@ -218,7 +218,7 @@ android:padding="@dimen/grid_spacing" android:singleLine="true" android:text="@string/description" - android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" + android:textAppearance="?textAppearanceTitleSmall" app:layout_constraintEnd_toStartOf="@id/button_description_more" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/button_read" /> @@ -274,7 +274,7 @@ android:padding="@dimen/grid_spacing" android:singleLine="true" android:text="@string/tracking" - android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" + android:textAppearance="?textAppearanceTitleSmall" app:layout_constraintEnd_toStartOf="@id/button_scrobbling_more" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/chips_tags" /> @@ -343,7 +343,7 @@ android:padding="@dimen/grid_spacing" android:singleLine="true" android:text="@string/related_manga" - android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" + android:textAppearance="?textAppearanceTitleSmall" app:layout_constraintEnd_toStartOf="@id/button_related_more" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/recyclerView_scrobbling" /> diff --git a/app/src/main/res/layout-w600dp-land/activity_settings.xml b/app/src/main/res/layout-w600dp-land/activity_settings.xml index 4b3d15d9e..1800be1f7 100644 --- a/app/src/main/res/layout-w600dp-land/activity_settings.xml +++ b/app/src/main/res/layout-w600dp-land/activity_settings.xml @@ -47,7 +47,7 @@ android:gravity="center_vertical|start" android:padding="8dp" android:singleLine="true" - android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" + android:textAppearance="?textAppearanceTitleSmall" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/container_master" app:layout_constraintTop_toTopOf="parent" @@ -72,4 +72,15 @@ app:layout_constraintStart_toEndOf="@id/container_master" app:layout_constraintTop_toTopOf="parent" /> + + diff --git a/app/src/main/res/layout/activity_appwidget_shelf.xml b/app/src/main/res/layout/activity_appwidget_shelf.xml index 8fae4aa9a..c69f0d86b 100644 --- a/app/src/main/res/layout/activity_appwidget_shelf.xml +++ b/app/src/main/res/layout/activity_appwidget_shelf.xml @@ -51,7 +51,7 @@ android:paddingEnd="?listPreferredItemPaddingEnd" android:singleLine="true" android:text="@string/favourites_categories" - android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" /> + android:textAppearance="?textAppearanceTitleSmall" /> diff --git a/app/src/main/res/layout/activity_details.xml b/app/src/main/res/layout/activity_details.xml index ecb78409f..a4c2ac740 100644 --- a/app/src/main/res/layout/activity_details.xml +++ b/app/src/main/res/layout/activity_details.xml @@ -227,7 +227,7 @@ android:padding="@dimen/grid_spacing" android:singleLine="true" android:text="@string/description" - android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" + android:textAppearance="?textAppearanceTitleSmall" app:layout_constraintEnd_toStartOf="@id/button_description_more" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/button_read" /> @@ -283,7 +283,7 @@ android:padding="@dimen/grid_spacing" android:singleLine="true" android:text="@string/tracking" - android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" + android:textAppearance="?textAppearanceTitleSmall" app:layout_constraintEnd_toStartOf="@id/button_scrobbling_more" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/chips_tags" /> @@ -352,7 +352,7 @@ android:padding="@dimen/grid_spacing" android:singleLine="true" android:text="@string/related_manga" - android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader" + android:textAppearance="?textAppearanceTitleSmall" app:layout_constraintEnd_toStartOf="@id/button_related_more" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/recyclerView_scrobbling" /> diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 897aef412..cb1fa645d 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -26,4 +26,11 @@ android:layout_height="match_parent" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" /> + + diff --git a/app/src/main/res/layout/activity_sources_catalog.xml b/app/src/main/res/layout/activity_sources_catalog.xml index e2c1a7b09..4e9273ec6 100644 --- a/app/src/main/res/layout/activity_sources_catalog.xml +++ b/app/src/main/res/layout/activity_sources_catalog.xml @@ -12,8 +12,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true" - app:liftOnScrollColor="@null" - app:liftOnScroll="false"> + app:liftOnScroll="false" + app:liftOnScrollColor="@null"> diff --git a/app/src/main/res/layout/dialog_download.xml b/app/src/main/res/layout/dialog_download.xml new file mode 100644 index 000000000..16841ae30 --- /dev/null +++ b/app/src/main/res/layout/dialog_download.xml @@ -0,0 +1,224 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +