diff --git a/.github/ISSUE_TEMPLATE/report_issue.yml b/.github/ISSUE_TEMPLATE/report_issue.yml index 69983d414..c640ff3c0 100644 --- a/.github/ISSUE_TEMPLATE/report_issue.yml +++ b/.github/ISSUE_TEMPLATE/report_issue.yml @@ -44,7 +44,7 @@ body: label: Kotatsu version description: You can find your Kotatsu version in **Settings → About**. placeholder: | - Example: "3.2" + Example: "3.2.3" validations: required: true @@ -87,7 +87,7 @@ body: required: true - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new). required: true - - label: I have updated the app to version **[3.2](https://github.com/nv95/Kotatsu/releases/latest)**. + - label: I have updated the app to version **[3.2.3](https://github.com/nv95/Kotatsu/releases/latest)**. required: true - label: I will fill out all of the requested information in this form. required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/request_feature.yml b/.github/ISSUE_TEMPLATE/request_feature.yml index a46a0648f..bae1b501c 100644 --- a/.github/ISSUE_TEMPLATE/request_feature.yml +++ b/.github/ISSUE_TEMPLATE/request_feature.yml @@ -33,7 +33,7 @@ body: required: true - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new). required: true - - label: I have updated the app to version **[3.2](https://github.com/nv95/Kotatsu/releases/latest)**. + - label: I have updated the app to version **[3.2.3](https://github.com/nv95/Kotatsu/releases/latest)**. required: true - label: I will fill out all of the requested information in this form. required: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index d4fcf16ce..3ba4daee9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /.idea/navEditor.xml /.idea/assetWizardSettings.xml /.idea/kotlinScripting.xml +/.idea/deploymentTargetDropDown.xml .DS_Store /build /captures diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml deleted file mode 100644 index 27370aa28..000000000 --- a/.idea/deploymentTargetDropDown.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index a6fb1fbe4..2bcd23609 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -4,6 +4,9 @@ + + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9cec1af6c..000000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: android -dist: trusty -android: - components: - - android-30 - - build-tools-30.0.3 - - platform-tools-30.0.5 - - tools -before_install: - - yes | sdkmanager "platforms;android-30" -script: ./gradlew -Dorg.gradle.jvmargs=-Xmx1536m assembleDebug lintDebug \ No newline at end of file diff --git a/README.md b/README.md index b079e87af..cea436ae3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Kotatsu is a free and open source manga reader for Android. -![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) +![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) ### Download diff --git a/app/build.gradle b/app/build.gradle index ff599eb34..e7442c75e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,8 +14,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 32 - versionCode 405 - versionName '3.2.1' + versionCode 407 + versionName '3.2.3' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -49,14 +49,15 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() freeCompilerArgs += [ - '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', - '-opt-in=kotlinx.coroutines.FlowPreview', - '-opt-in=kotlin.contracts.ExperimentalContracts', + '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', + '-opt-in=kotlinx.coroutines.FlowPreview', + '-opt-in=kotlin.contracts.ExperimentalContracts', + '-opt-in=coil.annotation.ExperimentalCoilApi', ] } lint { abortOnError false - disable 'MissingTranslation', 'PrivateResource' + disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged' } testOptions { unitTests.includeAndroidResources = true @@ -65,7 +66,7 @@ android { } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) - implementation('com.github.nv95:kotatsu-parsers:090ad4b256') { + implementation('com.github.nv95:kotatsu-parsers:05a93e2380') { exclude group: 'org.json', module: 'json' } @@ -86,7 +87,7 @@ dependencies { implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.work:work-runtime-ktx:2.7.1' - implementation 'com.google.android.material:material:1.6.0-rc01' + implementation 'com.google.android.material:material:1.7.0-alpha01' //noinspection LifecycleAnnotationProcessorWithJava8 kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1' @@ -95,21 +96,22 @@ dependencies { kapt 'androidx.room:room-compiler:2.4.2' implementation 'com.squareup.okhttp3:okhttp:4.9.3' - implementation 'com.squareup.okio:okio:3.0.0' + implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3' + implementation 'com.squareup.okio:okio:3.1.0' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' - implementation 'io.insert-koin:koin-android:3.1.6' - implementation 'io.coil-kt:coil-base:1.4.0' + implementation 'io.insert-koin:koin-android:3.2.0' + implementation 'io.coil-kt:coil-base:2.0.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.github.solkin:disk-lru-cache:1.4' - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' + debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' testImplementation 'junit:junit:4.13.2' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1' - testImplementation 'io.insert-koin:koin-test-junit4:3.1.5' + testImplementation 'io.insert-koin:koin-test-junit4:3.2.0' androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0' diff --git a/app/src/debug/java/org/koitharu/kotatsu/utils/ext/DebugExt.kt b/app/src/debug/java/org/koitharu/kotatsu/utils/ext/DebugExt.kt new file mode 100644 index 000000000..e00bb6a83 --- /dev/null +++ b/app/src/debug/java/org/koitharu/kotatsu/utils/ext/DebugExt.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.utils.ext + +fun Throwable.printStackTraceDebug() = printStackTrace() \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7e15a761e..2d3c76ebf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -83,7 +83,8 @@ + android:label="@string/manga_shelf" + android:theme="@style/Theme.Kotatsu.DialogWhenLarge"> @@ -101,8 +102,12 @@ + android:launchMode="singleTop" + android:theme="@style/Theme.Kotatsu.DialogWhenLarge" /> + diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index 7ec6a7d0b..576e688c0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -7,6 +7,7 @@ import androidx.fragment.app.strictmode.FragmentStrictMode import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import org.koitharu.kotatsu.bookmarks.bookmarksModule import org.koitharu.kotatsu.core.db.databaseModule import org.koitharu.kotatsu.core.github.githubModule import org.koitharu.kotatsu.core.network.networkModule @@ -69,6 +70,7 @@ class KotatsuApp : Application() { appWidgetModule, suggestionsModule, syncModule, + bookmarksModule, ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt index 09a970eee..b3b32dc1f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt @@ -9,54 +9,58 @@ import okhttp3.OkHttpClient import okhttp3.Request import org.koin.core.component.KoinComponent import org.koin.core.component.get -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.medianOrNull +import java.io.File import java.io.InputStream import java.util.zip.ZipFile +import kotlin.math.roundToInt object MangaUtils : KoinComponent { + private const val MIN_WEBTOON_RATIO = 2 + /** * Automatic determine type of manga by page size * @return ReaderMode.WEBTOON if page is wide */ - suspend fun determineMangaIsWebtoon(pages: List): Boolean? { - try { - val page = pages.medianOrNull() ?: return null - val url = MangaRepository(page.source).getPageUrl(page) - val uri = Uri.parse(url) - val size = if (uri.scheme == "cbz") { + suspend fun determineMangaIsWebtoon(pages: List): Boolean { + val pageIndex = (pages.size * 0.3).roundToInt() + val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" } + val url = MangaRepository(page.source).getPageUrl(page) + val uri = Uri.parse(url) + val size = if (uri.scheme == "cbz") { + runInterruptible(Dispatchers.IO) { + val zip = ZipFile(uri.schemeSpecificPart) + val entry = zip.getEntry(uri.fragment) + zip.getInputStream(entry).use { + getBitmapSize(it) + } + } + } else { + val request = Request.Builder() + .url(url) + .get() + .header(CommonHeaders.REFERER, page.referer) + .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) + .build() + get().newCall(request).await().use { runInterruptible(Dispatchers.IO) { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - zip.getInputStream(entry).use { - getBitmapSize(it) - } - } - } else { - val request = Request.Builder() - .url(url) - .get() - .header(CommonHeaders.REFERER, page.referer) - .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) - .build() - get().newCall(request).await().use { - runInterruptible(Dispatchers.IO) { - getBitmapSize(it.body?.byteStream()) - } + getBitmapSize(it.body?.byteStream()) } } - return size.width * 2 < size.height - } catch (e: Exception) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } - return null } + return size.width * MIN_WEBTOON_RATIO < size.height + } + + suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.path, options)?.recycle() + options.outMimeType } private fun getBitmapSize(input: InputStream?): Size { @@ -69,4 +73,4 @@ object MangaUtils : KoinComponent { check(imageHeight > 0 && imageWidth > 0) return Size(imageWidth, imageHeight) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt new file mode 100644 index 000000000..43c9bf7e4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.base.domain + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.utils.ext.processLifecycleScope + +fun interface ReversibleHandle { + + suspend fun reverse() +} + +fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) { + reverse() +} + +operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle { + this.reverse() + other.reverse() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt index 0672b880f..75503afc5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt @@ -9,11 +9,12 @@ import android.view.ViewGroup.LayoutParams import androidx.appcompat.app.AppCompatDialog import androidx.core.view.updateLayoutParams import androidx.viewbinding.ViewBinding -import com.google.android.material.R as materialR import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog +import com.google.android.material.R as materialR abstract class BaseBottomSheet : BottomSheetDialogFragment() { @@ -43,7 +44,9 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return if (resources.getBoolean(R.bool.is_tablet)) { AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog) - } else super.onCreateDialog(savedInstanceState) + } else { + AppBottomSheetDialog(requireContext(), theme) + } } protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt index 64317e4a7..e43ca8877 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt @@ -7,10 +7,12 @@ import android.view.View import android.view.WindowManager import androidx.viewbinding.ViewBinding +@Suppress("DEPRECATION") private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN +@Suppress("DEPRECATION") private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or @@ -18,7 +20,8 @@ private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY -abstract class BaseFullscreenActivity : BaseActivity(), +abstract class BaseFullscreenActivity : + BaseActivity(), View.OnSystemUiVisibilityChangeListener { override fun onCreate(savedInstanceState: Bundle?) { @@ -35,16 +38,19 @@ abstract class BaseFullscreenActivity : BaseActivity(), showSystemUI() } + @Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith") @Deprecated("Deprecated in Java") final override fun onSystemUiVisibilityChange(visibility: Int) { onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0) } // TODO WindowInsetsControllerCompat works incorrect + @Suppress("DEPRECATION") protected fun hideSystemUI() { window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN } + @Suppress("DEPRECATION") protected fun showSystemUI() { window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt index 39233e28f..f17e3aa9f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt @@ -1,18 +1,25 @@ package org.koitharu.kotatsu.base.ui +import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.* -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug abstract class BaseViewModel : ViewModel() { - val onError = SingleLiveEvent() - val isLoading = CountedBooleanLiveData() + protected val loadingCounter = CountedBooleanLiveData() + protected val errorEvent = SingleLiveEvent() + + val onError: LiveData + get() = errorEvent + + val isLoading: LiveData + get() = loadingCounter protected fun launchJob( context: CoroutineContext = EmptyCoroutineContext, @@ -25,20 +32,18 @@ abstract class BaseViewModel : ViewModel() { start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit ): Job = viewModelScope.launch(context + createErrorHandler(), start) { - isLoading.postValue(true) + loadingCounter.increment() try { block() } finally { - isLoading.postValue(false) + loadingCounter.decrement() } } private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> - if (BuildConfig.DEBUG) { - throwable.printStackTrace() - } + throwable.printStackTraceDebug() if (throwable !is CancellationException) { - onError.postCall(throwable) + errorEvent.postCall(throwable) } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt new file mode 100644 index 000000000..d3b911ace --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.base.ui.dialog + +import android.content.Context +import android.graphics.Color +import android.view.View +import com.google.android.material.bottomsheet.BottomSheetDialog + +class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) { + + /** + * https://github.com/material-components/material-components-android/issues/2582 + */ + @Suppress("DEPRECATION") + override fun onAttachedToWindow() { + val window = window + val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0 + super.onAttachedToWindow() + if (window != null) { + // If the navigation bar is translucent at all, the BottomSheet should be edge to edge + val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF + if (drawEdgeToEdge) { + // Copied from super.onAttachedToWindow: + val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + // Fix super-class's window flag bug by respecting the intial system UI visibility: + window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt new file mode 100644 index 000000000..650e816c5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.base.ui.list + +import android.view.View +import android.view.View.OnClickListener +import android.view.View.OnLongClickListener +import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder + +class AdapterDelegateClickListenerAdapter( + private val adapterDelegate: AdapterDelegateViewBindingViewHolder, + private val clickListener: OnListItemClickListener, +) : OnClickListener, OnLongClickListener { + + override fun onClick(v: View) { + clickListener.onItemClick(adapterDelegate.item, v) + } + + override fun onLongClick(v: View): Boolean { + return clickListener.onItemLongClick(adapterDelegate.item, v) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt index cb54ef7db..d654e541d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt @@ -1,20 +1,31 @@ package org.koitharu.kotatsu.base.ui.util -import androidx.lifecycle.MutableLiveData +import androidx.annotation.AnyThread +import androidx.lifecycle.LiveData +import java.util.concurrent.atomic.AtomicInteger -class CountedBooleanLiveData : MutableLiveData(false) { +class CountedBooleanLiveData : LiveData(false) { - private var counter = 0 + private val counter = AtomicInteger(0) - override fun setValue(value: Boolean) { - if (value) { - counter++ - } else { - counter-- + @AnyThread + fun increment() { + if (counter.getAndIncrement() == 0) { + postValue(true) } - val newValue = counter > 0 - if (newValue != this.value) { - super.setValue(newValue) + } + + @AnyThread + fun decrement() { + if (counter.decrementAndGet() == 0) { + postValue(false) + } + } + + @AnyThread + fun reset() { + if (counter.getAndSet(0) != 0) { + postValue(false) } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt index 18d7262dc..bdaf8f476 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt @@ -36,8 +36,7 @@ class ListItemTextView @JvmOverloads constructor( init { context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) { - val itemRippleColor = getColorStateList(R.styleable.ListItemTextView_rippleColor) - ?: getRippleColorFallback(context) + val itemRippleColor = getRippleColor(context) val shape = createShapeDrawable(this) background = RippleDrawable( RippleUtils.sanitizeRippleDrawableColor(itemRippleColor), @@ -108,7 +107,7 @@ class ListItemTextView @JvmOverloads constructor( ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0), ).build() val shapeDrawable = MaterialShapeDrawable(shapeAppearance) - shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundTint) + shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundFillColor) return InsetDrawable( shapeDrawable, ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0), @@ -118,7 +117,7 @@ class ListItemTextView @JvmOverloads constructor( ) } - private fun getRippleColorFallback(context: Context): ColorStateList { + private fun getRippleColor(context: Context): ColorStateList { return context.getThemeColorStateList(android.R.attr.colorControlHighlight) ?: ColorStateList.valueOf(Color.TRANSPARENT) } diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt new file mode 100644 index 000000000..4a8294765 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.bookmarks + +import org.koin.dsl.module +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository + +val bookmarksModule + get() = module { + + factory { BookmarksRepository(get()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt new file mode 100644 index 000000000..0959b3362 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt @@ -0,0 +1,28 @@ +package org.koitharu.kotatsu.bookmarks.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import org.koitharu.kotatsu.core.db.entity.MangaEntity + +@Entity( + tableName = "bookmarks", + primaryKeys = ["manga_id", "page_id"], + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE + ), + ] +) +class BookmarkEntity( + @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, + @ColumnInfo(name = "page_id", index = true) val pageId: Long, + @ColumnInfo(name = "chapter_id") val chapterId: Long, + @ColumnInfo(name = "page") val page: Int, + @ColumnInfo(name = "scroll") val scroll: Int, + @ColumnInfo(name = "image") val imageUrl: String, + @ColumnInfo(name = "created_at") val createdAt: Long, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt new file mode 100644 index 000000000..4bd63d65d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.bookmarks.data + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity + +class BookmarkWithManga( + @Embedded val bookmark: BookmarkEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "manga_id" + ) + val manga: MangaEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "tag_id", + associateBy = Junction(MangaTagsEntity::class) + ) + val tags: List, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt new file mode 100644 index 000000000..dd023be7a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt @@ -0,0 +1,26 @@ +package org.koitharu.kotatsu.bookmarks.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class BookmarksDao { + + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page") + abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow + + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC") + abstract fun observe(mangaId: Long): Flow> + + @Insert + abstract suspend fun insert(entity: BookmarkEntity) + + @Delete + abstract suspend fun delete(entity: BookmarkEntity) + + @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") + abstract suspend fun delete(mangaId: Long, pageId: Long) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt new file mode 100644 index 000000000..981aa05ea --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.bookmarks.data + +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.core.db.entity.toManga +import org.koitharu.kotatsu.core.db.entity.toMangaTags +import org.koitharu.kotatsu.parsers.model.Manga +import java.util.* + +fun BookmarkWithManga.toBookmark() = bookmark.toBookmark( + manga.toManga(tags.toMangaTags()) +) + +fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark( + manga = manga, + pageId = pageId, + chapterId = chapterId, + page = page, + scroll = scroll, + imageUrl = imageUrl, + createdAt = Date(createdAt), +) + +fun Bookmark.toEntity() = BookmarkEntity( + mangaId = manga.id, + pageId = pageId, + chapterId = chapterId, + page = page, + scroll = scroll, + imageUrl = imageUrl, + createdAt = createdAt.time, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt new file mode 100644 index 000000000..0b76c6537 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt @@ -0,0 +1,43 @@ +package org.koitharu.kotatsu.bookmarks.domain + +import org.koitharu.kotatsu.parsers.model.Manga +import java.util.* + +class Bookmark( + val manga: Manga, + val pageId: Long, + val chapterId: Long, + val page: Int, + val scroll: Int, + val imageUrl: String, + val createdAt: Date, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Bookmark + + if (manga != other.manga) return false + if (pageId != other.pageId) return false + if (chapterId != other.chapterId) return false + if (page != other.page) return false + if (scroll != other.scroll) return false + if (imageUrl != other.imageUrl) return false + if (createdAt != other.createdAt) return false + + return true + } + + override fun hashCode(): Int { + var result = manga.hashCode() + result = 31 * result + pageId.hashCode() + result = 31 * result + chapterId.hashCode() + result = 31 * result + page + result = 31 * result + scroll + result = 31 * result + imageUrl.hashCode() + result = 31 * result + createdAt.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt new file mode 100644 index 000000000..df63c03aa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt @@ -0,0 +1,38 @@ +package org.koitharu.kotatsu.bookmarks.domain + +import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.bookmarks.data.toBookmark +import org.koitharu.kotatsu.bookmarks.data.toEntity +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.entity.toEntities +import org.koitharu.kotatsu.core.db.entity.toEntity +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.mapItems + +class BookmarksRepository( + private val db: MangaDatabase, +) { + + fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow { + return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) } + } + + fun observeBookmarks(manga: Manga): Flow> { + return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) } + } + + suspend fun addBookmark(bookmark: Bookmark) { + db.withTransaction { + val tags = bookmark.manga.tags.toEntities() + db.tagsDao.upsert(tags) + db.mangaDao.upsert(bookmark.manga.toEntity(), tags) + db.bookmarksDao.insert(bookmark.toEntity()) + } + } + + suspend fun removeBookmark(mangaId: Long, pageId: Long) { + db.bookmarksDao.delete(mangaId, pageId) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt new file mode 100644 index 000000000..f8aa0e638 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt @@ -0,0 +1,51 @@ +package org.koitharu.kotatsu.bookmarks.ui + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import coil.request.Disposable +import coil.size.Scale +import coil.util.CoilUtils +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.databinding.ItemBookmarkBinding +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.referer + +fun bookmarkListAD( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) } +) { + + var imageRequest: Disposable? = null + val listener = AdapterDelegateClickListenerAdapter(this, clickListener) + + binding.root.setOnClickListener(listener) + binding.root.setOnLongClickListener(listener) + + bind { + imageRequest?.dispose() + imageRequest = binding.imageViewThumb.newImageRequest(item.imageUrl) + .referer(item.manga.publicUrl) + .placeholder(R.drawable.ic_placeholder) + .fallback(R.drawable.ic_placeholder) + .error(R.drawable.ic_placeholder) + .allowRgb565(true) + .scale(Scale.FILL) + .lifecycle(lifecycleOwner) + .enqueueWith(coil) + } + + onViewRecycled { + imageRequest?.dispose() + imageRequest = null + CoilUtils.dispose(binding.imageViewThumb) + binding.imageViewThumb.setImageDrawable(null) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt new file mode 100644 index 000000000..92040bc97 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt @@ -0,0 +1,30 @@ +package org.koitharu.kotatsu.bookmarks.ui + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.bookmarks.domain.Bookmark + +class BookmarksAdapter( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter( + DiffCallback(), + bookmarkListAD(coil, lifecycleOwner, clickListener) +) { + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean { + return oldItem.manga.id == newItem.manga.id && oldItem.chapterId == newItem.chapterId + } + + override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean { + return oldItem.imageUrl == newItem.imageUrl + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt index 3d2f668a6..4b42b4c60 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt @@ -121,6 +121,7 @@ class BackupRepository(private val db: MangaDatabase) { jo.put("sort_key", sortKey) jo.put("title", title) jo.put("order", order) + jo.put("track", track) return jo } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt index ba4a8b6a3..57fd4d6ee 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt @@ -104,6 +104,7 @@ class RestoreRepository(private val db: MangaDatabase) { sortKey = json.getInt("sort_key"), title = json.getString("title"), order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name, + track = json.getBooleanOrDefault("track", true), ) private fun parseFavourite(json: JSONObject) = FavouriteEntity( diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt index dadbb05eb..215d02259 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt @@ -5,5 +5,5 @@ import org.koin.dsl.module val databaseModule get() = module { - single { MangaDatabase.create(androidContext()) } + single { MangaDatabase(androidContext()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt index 35c52192c..6257a8456 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt @@ -10,8 +10,8 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba override fun onCreate(db: SupportSQLiteDatabase) { db.execSQL( - "INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)", - arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name) + "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track) VALUES (?,?,?,?,?)", + arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name, 1) ) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt index a4e5812d9..0714c0fcc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity +import org.koitharu.kotatsu.bookmarks.data.BookmarksDao import org.koitharu.kotatsu.core.db.dao.* import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.migrations.* @@ -20,9 +22,9 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity entities = [ MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, - TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class + TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ], - version = 9 + version = 11, ) abstract class MangaDatabase : RoomDatabase() { @@ -44,30 +46,24 @@ abstract class MangaDatabase : RoomDatabase() { abstract val suggestionDao: SuggestionDao - companion object { + abstract val bookmarksDao: BookmarksDao +} - const val TABLE_FAVOURITES = "favourites" - const val TABLE_MANGA = "manga" - const val TABLE_TAGS = "tags" - const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories" - const val TABLE_HISTORY = "history" - const val TABLE_MANGA_TAGS = "manga_tags" - - fun create(context: Context): MangaDatabase = Room.databaseBuilder( - context, - MangaDatabase::class.java, - "kotatsu-db" - ).addMigrations( - Migration1To2(), - Migration2To3(), - Migration3To4(), - Migration4To5(), - Migration5To6(), - Migration6To7(), - Migration7To8(), - Migration8To9(), - ).addCallback( - DatabasePrePopulateCallback(context.resources) - ).build() - } -} \ No newline at end of file +fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( + context, + MangaDatabase::class.java, + "kotatsu-db" +).addMigrations( + Migration1To2(), + Migration2To3(), + Migration3To4(), + Migration4To5(), + Migration5To6(), + Migration6To7(), + Migration7To8(), + Migration8To9(), + Migration9To10(), + Migration10To11(), +).addCallback( + DatabasePrePopulateCallback(context.resources) +).build() \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt new file mode 100644 index 000000000..1e47f1e9a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.core.db + +const val TABLE_FAVOURITES = "favourites" +const val TABLE_MANGA = "manga" +const val TABLE_TAGS = "tags" +const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories" +const val TABLE_HISTORY = "history" +const val TABLE_MANGA_TAGS = "manga_tags" \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt index 4bd188966..f8352524b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt @@ -10,6 +10,9 @@ abstract class TracksDao { @Query("SELECT * FROM tracks") abstract suspend fun findAll(): List + @Query("SELECT * FROM tracks WHERE manga_id IN (:ids)") + abstract suspend fun findAll(ids: Collection): List + @Query("SELECT * FROM tracks WHERE manga_id = :mangaId") abstract suspend fun find(mangaId: Long): TrackEntity? diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt index 1447e914d..d3b64295a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt @@ -3,7 +3,7 @@ package org.koitharu.kotatsu.core.db.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA +import org.koitharu.kotatsu.core.db.TABLE_MANGA @Entity(tableName = TABLE_MANGA) class MangaEntity( diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt index ef13b2824..bc343f784 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt @@ -3,7 +3,7 @@ package org.koitharu.kotatsu.core.db.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA_TAGS +import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS @Entity( tableName = TABLE_MANGA_TAGS, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt index 368b675df..23f61d6d8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt @@ -3,7 +3,7 @@ package org.koitharu.kotatsu.core.db.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_TAGS +import org.koitharu.kotatsu.core.db.TABLE_TAGS @Entity(tableName = TABLE_TAGS) class TagEntity( diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt new file mode 100644 index 000000000..5d80708fe --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt @@ -0,0 +1,26 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration10To11 : Migration(10, 11) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `bookmarks` ( + `manga_id` INTEGER NOT NULL, + `page_id` INTEGER NOT NULL, + `chapter_id` INTEGER NOT NULL, + `page` INTEGER NOT NULL, + `scroll` INTEGER NOT NULL, + `image` TEXT NOT NULL, + `created_at` INTEGER NOT NULL, + PRIMARY KEY(`manga_id`, `page_id`), + FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE ) + """.trimIndent() + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt new file mode 100644 index 000000000..59cba96ef --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration9To10 : Migration(9, 10) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt index de5256337..58d8d22c6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt @@ -4,7 +4,5 @@ import org.koin.dsl.module val githubModule get() = module { - single { - GithubRepository(get()) - } + factory { GithubRepository(get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt index 09557cb47..88304755b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt @@ -54,27 +54,23 @@ class VersionId( return result } - companion object { - - private fun variantWeight(variantType: String) = - when (variantType.lowercase(Locale.ROOT)) { - "a", "alpha" -> 1 - "b", "beta" -> 2 - "rc" -> 4 - "" -> 8 - else -> 0 - } - - fun parse(versionName: String): VersionId { - val parts = versionName.substringBeforeLast('-').split('.') - val variant = versionName.substringAfterLast('-', "") - return VersionId( - major = parts.getOrNull(0)?.toIntOrNull() ?: 0, - minor = parts.getOrNull(1)?.toIntOrNull() ?: 0, - build = parts.getOrNull(2)?.toIntOrNull() ?: 0, - variantType = variant.filter(Char::isLetter), - variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0 - ) - } + private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) { + "a", "alpha" -> 1 + "b", "beta" -> 2 + "rc" -> 4 + "" -> 8 + else -> 0 } +} + +fun VersionId(versionName: String): VersionId { + val parts = versionName.substringBeforeLast('-').split('.') + val variant = versionName.substringAfterLast('-', "") + return VersionId( + major = parts.getOrNull(0)?.toIntOrNull() ?: 0, + minor = parts.getOrNull(1)?.toIntOrNull() ?: 0, + build = parts.getOrNull(2)?.toIntOrNull() ?: 0, + variantType = variant.filter(Char::isLetter), + variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0, + ) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt index 655a0eb08..798ec2fbd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt @@ -1,9 +1,9 @@ package org.koitharu.kotatsu.core.model import android.os.Parcelable +import java.util.* import kotlinx.parcelize.Parcelize import org.koitharu.kotatsu.parsers.model.SortOrder -import java.util.* @Parcelize data class FavouriteCategory( @@ -12,4 +12,5 @@ data class FavouriteCategory( val sortKey: Int, val order: SortOrder, val createdAt: Date, + val isTrackingEnabled: Boolean, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt new file mode 100644 index 000000000..7c3c2db6e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt @@ -0,0 +1,84 @@ +package org.koitharu.kotatsu.core.network + +import okhttp3.Cache +import okhttp3.Dns +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.dnsoverhttps.DnsOverHttps +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import java.net.InetAddress +import java.net.UnknownHostException + +class DoHManager( + cache: Cache, + private val settings: AppSettings, +) : Dns { + + private val bootstrapClient = OkHttpClient.Builder().cache(cache).build() + + private var cachedDelegate: Dns? = null + private var cachedProvider: DoHProvider? = null + + override fun lookup(hostname: String): List { + return getDelegate().lookup(hostname) + } + + @Synchronized + private fun getDelegate(): Dns { + var delegate = cachedDelegate + val provider = settings.dnsOverHttps + if (delegate == null || provider != cachedProvider) { + delegate = createDelegate(provider) + cachedDelegate = delegate + cachedProvider = provider + } + return delegate + } + + private fun createDelegate(provider: DoHProvider): Dns = when (provider) { + DoHProvider.NONE -> Dns.SYSTEM + DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient) + .url("https://dns.google/dns-query".toHttpUrl()) + .bootstrapDnsHosts( + listOfNotNull( + tryGetByIp("8.8.4.4"), + tryGetByIp("8.8.8.8"), + tryGetByIp("2001:4860:4860::8888"), + tryGetByIp("2001:4860:4860::8844"), + ) + ).build() + DoHProvider.CLOUDFLARE -> DnsOverHttps.Builder().client(bootstrapClient) + .url("https://cloudflare-dns.com/dns-query".toHttpUrl()) + .bootstrapDnsHosts( + listOfNotNull( + tryGetByIp("162.159.36.1"), + tryGetByIp("162.159.46.1"), + tryGetByIp("1.1.1.1"), + tryGetByIp("1.0.0.1"), + tryGetByIp("162.159.132.53"), + tryGetByIp("2606:4700:4700::1111"), + tryGetByIp("2606:4700:4700::1001"), + tryGetByIp("2606:4700:4700::0064"), + tryGetByIp("2606:4700:4700::6400"), + ) + ).build() + DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient) + .url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl()) + .bootstrapDnsHosts( + listOfNotNull( + tryGetByIp("94.140.14.140"), + tryGetByIp("94.140.14.141"), + tryGetByIp("2a10:50c0::1:ff"), + tryGetByIp("2a10:50c0::2:ff"), + ) + ).build() + } + + private fun tryGetByIp(ip: String): InetAddress? = try { + InetAddress.getByName(ip) + } catch (e: UnknownHostException) { + e.printStackTraceDebug() + null + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt new file mode 100644 index 000000000..e17db70a7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.core.network + +enum class DoHProvider { + + NONE, GOOGLE, CLOUDFLARE, ADGUARD +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt index 48b009a33..2af4c215e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.core.network -import java.util.concurrent.TimeUnit import okhttp3.CookieJar import okhttp3.OkHttpClient import org.koin.dsl.bind @@ -8,17 +7,20 @@ import org.koin.dsl.module import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.MangaLoaderContext +import java.util.concurrent.TimeUnit val networkModule get() = module { single { AndroidCookieJar() } bind CookieJar::class single { + val cache = get().createHttpCache() OkHttpClient.Builder().apply { connectTimeout(20, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS) cookieJar(get()) - cache(get().createHttpCache()) + dns(DoHManager(cache, get())) + cache(cache) addInterceptor(UserAgentInterceptor()) addInterceptor(CloudFlareInterceptor()) }.build() diff --git a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt index 98ebe3a6a..9e8b18a52 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt @@ -5,12 +5,12 @@ import android.content.Context import android.content.pm.ShortcutManager import android.media.ThumbnailUtils import android.os.Build +import android.util.Size import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import coil.ImageLoader import coil.request.ImageRequest -import coil.size.PixelSize import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R @@ -54,7 +54,7 @@ class ShortcutsRepository( val bmp = coil.execute( ImageRequest.Builder(context) .data(manga.coverUrl) - .size(iconSize) + .size(iconSize.width, iconSize.height) .build() ).requireBitmap() ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0) @@ -74,14 +74,14 @@ class ShortcutsRepository( ) } - private fun getIconSize(context: Context): PixelSize { + private fun getIconSize(context: Context): Size { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { (context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let { - PixelSize(it.iconMaxWidth, it.iconMaxHeight) + Size(it.iconMaxWidth, it.iconMaxHeight) } } else { (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let { - PixelSize(it, it) + Size(it, it) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt index 4a386d3f8..ba5412a50 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt @@ -2,17 +2,19 @@ package org.koitharu.kotatsu.core.parser import android.net.Uri import coil.map.Mapper +import coil.request.Options import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import org.koitharu.kotatsu.parsers.model.MangaSource -class FaviconMapper() : Mapper { +class FaviconMapper : Mapper { - override fun map(data: Uri): HttpUrl { + override fun map(data: Uri, options: Options): HttpUrl? { + if (data.scheme != "favicon") { + return null + } val mangaSource = MangaSource.valueOf(data.schemeSpecificPart) val repo = MangaRepository(mangaSource) as RemoteMangaRepository return repo.getFaviconUrl().toHttpUrl() } - - override fun handles(data: Uri) = data.scheme == "favicon" } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index d9178713c..b81fcbe7a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.model.ZoomMode +import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue @@ -78,7 +79,10 @@ class AppSettings(context: Context) { get() = prefs.getLong(KEY_APP_UPDATE, 0L) set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) } - val trackerNotifications: Boolean + val isTrackerEnabled: Boolean + get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true) + + val isTrackerNotificationsEnabled: Boolean get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true) var notificationSound: Uri @@ -95,8 +99,11 @@ class AppSettings(context: Context) { val readerAnimation: Boolean get() = prefs.getBoolean(KEY_READER_ANIMATION, false) - val isPreferRtlReader: Boolean - get() = prefs.getBoolean(KEY_READER_PREFER_RTL, false) + val defaultReaderMode: ReaderMode + get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD) + + val isReaderModeDetectionEnabled: Boolean + get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true) var historyGrouping: Boolean get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true) @@ -185,6 +192,9 @@ class AppSettings(context: Context) { get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false) set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) } + val dnsOverHttps: DoHProvider + get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) + fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean { return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { NETWORK_ALWAYS -> true @@ -269,15 +279,19 @@ class AppSettings(context: Context) { const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_READER_SWITCHERS = "reader_switchers" + const val KEY_TRACKER_ENABLED = "tracker_enabled" const val KEY_TRACK_SOURCES = "track_sources" + const val KEY_TRACK_CATEGORIES = "track_categories" const val KEY_TRACK_WARNING = "track_warning" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" const val KEY_NOTIFICATIONS_LIGHT = "notifications_light" + const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info" const val KEY_READER_ANIMATION = "reader_animation" - const val KEY_READER_PREFER_RTL = "reader_prefer_rtl" + const val KEY_READER_MODE = "reader_mode" + const val KEY_READER_MODE_DETECT = "reader_mode_detect" const val KEY_APP_PASSWORD = "app_password" const val KEY_PROTECT_APP = "protect_app" const val KEY_APP_VERSION = "app_version" @@ -297,6 +311,7 @@ class AppSettings(context: Context) { const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" + const val KEY_DOH = "doh" // About const val KEY_APP_UPDATE = "app_update" diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt new file mode 100644 index 000000000..88c62514c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt @@ -0,0 +1,35 @@ +package org.koitharu.kotatsu.core.prefs + +import androidx.lifecycle.liveData +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.flow.flow + +fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { + var lastValue: T = valueProducer() + emit(lastValue) + observe().collect { + if (it == key) { + val value = valueProducer() + if (value != lastValue) { + emit(value) + } + lastValue = value + } + } +} + +fun AppSettings.observeAsLiveData( + context: CoroutineContext, + key: String, + valueProducer: AppSettings.() -> T +) = liveData(context) { + emit(valueProducer()) + observe().collect { + if (it == key) { + val value = valueProducer() + if (value != latestValue) { + emit(value) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt index bfc8b7b83..9ec51d479 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt @@ -10,4 +10,4 @@ enum class ReaderMode(val id: Int) { fun valueOf(id: Int) = values().firstOrNull { it.id == id } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt index 20a7bf0c3..fb3216cb2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.util.Log import kotlin.system.exitProcess +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler { @@ -13,7 +14,7 @@ class AppCrashHandler(private val applicationContext: Context) : Thread.Uncaught try { applicationContext.startActivity(intent) } catch (t: Throwable) { - t.printStackTrace() + t.printStackTraceDebug() } Log.e("CRASH", e.message, e) exitProcess(1) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt index 26ccbf44a..54529b37c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt @@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.ui import coil.ComponentRegistry import coil.ImageLoader -import coil.util.CoilUtils +import coil.disk.DiskCache +import kotlinx.coroutines.Dispatchers import okhttp3.OkHttpClient import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.koitharu.kotatsu.core.parser.FaviconMapper +import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CbzFetcher val uiModule @@ -14,15 +16,23 @@ val uiModule single { val httpClientFactory = { get().newBuilder() - .cache(CoilUtils.createDefaultCache(androidContext())) + .cache(null) + .build() + } + val diskCacheFactory = { + val context = androidContext() + val rootDir = context.externalCacheDir ?: context.cacheDir + DiskCache.Builder() + .directory(rootDir.resolve(CacheDir.THUMBS.dir)) .build() } ImageLoader.Builder(androidContext()) .okHttpClient(httpClientFactory) - .launchInterceptorChainOnMainThread(false) - .componentRegistry( + .interceptorDispatcher(Dispatchers.Default) + .diskCache(diskCacheFactory) + .components( ComponentRegistry.Builder() - .add(CbzFetcher()) + .add(CbzFetcher.Factory()) .add(FaviconMapper()) .build() ).build() diff --git a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt index 7e3bd8622..916b75de1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt @@ -8,6 +8,6 @@ val detailsModule get() = module { viewModel { intent -> - DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get()) + DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/domain/BranchComparator.kt b/app/src/main/java/org/koitharu/kotatsu/details/domain/BranchComparator.kt new file mode 100644 index 000000000..d0b5a23c3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/domain/BranchComparator.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.details.domain + +class BranchComparator : Comparator { + + override fun compare(o1: String?, o2: String?): Int = compareValues(o1, o2) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index a1920bf80..f6ecc6f0d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -83,6 +83,9 @@ class DetailsActivity : viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onError.observe(this, ::onError) + viewModel.onShowToast.observe(this) { + binding.snackbar.show(messageText = getString(it), longDuration = false) + } registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index df4fc2de9..51d320e00 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.Insets import androidx.core.net.toUri import androidx.core.text.parseAsHtml +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader @@ -21,10 +22,14 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.widgets.ChipsView +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.databinding.FragmentDetailsBinding -import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -41,7 +46,8 @@ class DetailsFragment : BaseFragment(), View.OnClickListener, View.OnLongClickListener, - ChipsView.OnChipClickListener { + ChipsView.OnChipClickListener, + OnListItemClickListener { private val viewModel by sharedViewModel() private val coil by inject(mode = LazyThreadSafetyMode.NONE) @@ -69,6 +75,7 @@ class DetailsFragment : viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) + viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -76,6 +83,24 @@ class DetailsFragment : inflater.inflate(R.menu.opt_details_info, menu) } + override fun onItemClick(item: Bookmark, view: View) { + val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.measuredWidth, view.measuredHeight) + startActivity(ReaderActivity.newIntent(view.context, item), options.toBundle()) + } + + override fun onItemLongClick(item: Bookmark, view: View): Boolean { + val menu = PopupMenu(view.context, view) + menu.inflate(R.menu.popup_bookmark) + menu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_remove -> viewModel.removeBookmark(item) + } + true + } + menu.show() + return true + } + private fun onMangaUpdated(manga: Manga) { with(binding) { // Main @@ -176,11 +201,25 @@ class DetailsFragment : } } + private fun onBookmarksChanged(bookmarks: List) { + var adapter = binding.recyclerViewBookmarks.adapter as? BookmarksAdapter + binding.groupBookmarks.isGone = bookmarks.isEmpty() + if (adapter != null) { + adapter.items = bookmarks + } else { + adapter = BookmarksAdapter(coil, viewLifecycleOwner, this) + adapter.items = bookmarks + binding.recyclerViewBookmarks.adapter = adapter + val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing) + binding.recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing)) + } + } + override fun onClick(v: View) { val manga = viewModel.manga.value ?: return when (v.id) { R.id.button_favorite -> { - FavouriteCategoriesDialog.show(childFragmentManager, manga) + FavouriteCategoriesBottomSheet.show(childFragmentManager, manga) } R.id.button_read -> { val chapterId = viewModel.readingHistory.value?.chapterId @@ -283,7 +322,7 @@ class DetailsFragment : .target(binding.imageViewCover) if (currentCover != null) { request.data(manga.largeCoverUrl ?: return) - .placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey) + .placeholderMemoryCacheKey(CoilUtils.result(binding.imageViewCover)?.request?.memoryCacheKey) .fallback(currentCover) } else { request.crossfade(true) diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 0ee363efd..382899c3a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -1,121 +1,104 @@ package org.koitharu.kotatsu.details.ui -import androidx.core.os.LocaleListCompat -import androidx.lifecycle.asFlow -import androidx.lifecycle.asLiveData -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import androidx.lifecycle.* +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.ui.BaseViewModel -import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException -import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.ui.model.ChapterListItem -import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository 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.toTitleCase import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -import org.koitharu.kotatsu.utils.ext.iterator +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import java.io.IOException class DetailsViewModel( - private val intent: MangaIntent, + intent: MangaIntent, private val historyRepository: HistoryRepository, - private val favouritesRepository: FavouritesRepository, + favouritesRepository: FavouritesRepository, private val localMangaRepository: LocalMangaRepository, private val trackingRepository: TrackingRepository, - private val mangaDataRepository: MangaDataRepository, + mangaDataRepository: MangaDataRepository, + private val bookmarksRepository: BookmarksRepository, private val settings: AppSettings, ) : BaseViewModel() { + private val delegate = MangaDetailsDelegate( + intent = intent, + settings = settings, + mangaDataRepository = mangaDataRepository, + historyRepository = historyRepository, + localMangaRepository = localMangaRepository, + ) + private var loadingJob: Job - private val mangaData = MutableStateFlow(intent.manga) - private val selectedBranch = MutableStateFlow(null) - private val history = mangaData.mapNotNull { it?.id } - .distinctUntilChanged() - .flatMapLatest { mangaId -> - historyRepository.observeOne(mangaId) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + val onShowToast = SingleLiveEvent() - private val favourite = mangaData.mapNotNull { it?.id } - .distinctUntilChanged() - .flatMapLatest { mangaId -> - favouritesRepository.observeCategoriesIds(mangaId).map { it.isNotEmpty() } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) + private val history = historyRepository.observeOne(delegate.mangaId) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - private val newChapters = mangaData.mapNotNull { it?.id } - .distinctUntilChanged() - .mapLatest { mangaId -> - trackingRepository.getNewChaptersCount(mangaId) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) - - // Remote manga for saved and saved for remote - private val relatedManga = MutableStateFlow(null) - private val chaptersQuery = MutableStateFlow("") - - private val chaptersReversed = settings.observe() - .filter { it == AppSettings.KEY_REVERSE_CHAPTERS } - .map { settings.chaptersReverse } - .onStart { emit(settings.chaptersReverse) } + private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - val manga = mangaData.filterNotNull() - .asLiveData(viewModelScope.coroutineContext) - val favouriteCategories = favourite - .asLiveData(viewModelScope.coroutineContext) - val newChaptersCount = newChapters - .asLiveData(viewModelScope.coroutineContext) - val readingHistory = history - .asLiveData(viewModelScope.coroutineContext) - val isChaptersReversed = chaptersReversed - .asLiveData(viewModelScope.coroutineContext) + private val newChapters = viewModelScope.async(Dispatchers.Default) { + trackingRepository.getNewChaptersCount(delegate.mangaId) + } + + private val chaptersQuery = MutableStateFlow("") + + private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse } + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) + + val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext) + val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext) + val newChaptersCount = liveData(viewModelScope.coroutineContext) { emit(newChapters.await()) } + val readingHistory = history.asLiveData(viewModelScope.coroutineContext) + val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext) + + val bookmarks = delegate.manga.flatMapLatest { + if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) val onMangaRemoved = SingleLiveEvent() - val branches = mangaData.map { - it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() + val branches: LiveData> = delegate.manga.map { + val chapters = it?.chapters ?: return@map emptyList() + chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator()) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) val selectedBranchIndex = combine( branches.asFlow(), - selectedBranch + delegate.selectedBranch ) { branches, selected -> branches.indexOf(selected) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) - val isChaptersEmpty = mangaData.mapNotNull { m -> - m?.run { chapters.isNullOrEmpty() } + val isChaptersEmpty: LiveData = delegate.manga.map { m -> + m != null && m.chapters.isNullOrEmpty() }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false) val chapters = combine( combine( - mangaData.map { it?.chapters.orEmpty() }, - relatedManga, - history.map { it?.chapterId }, - newChapters, - selectedBranch - ) { chapters, related, currentId, newCount, branch -> - val relatedChapters = related?.chapters - if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) { - mapChaptersWithSource(chapters, relatedChapters, currentId, newCount, branch) - } else { - mapChapters(chapters, relatedChapters, currentId, newCount, branch) - } + delegate.manga, + delegate.relatedManga, + history, + delegate.selectedBranch, + ) { manga, related, history, branch -> + delegate.mapChapters(manga, related, history, newChapters.await(), branch) }, chaptersReversed, chaptersQuery, @@ -124,7 +107,7 @@ class DetailsViewModel( }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) val selectedBranchValue: String? - get() = selectedBranch.value + get() = delegate.selectedBranch.value init { loadingJob = doLoad() @@ -136,7 +119,11 @@ class DetailsViewModel( } fun deleteLocal() { - val m = mangaData.value ?: return + val m = delegate.manga.value + if (m == null) { + onShowToast.call(R.string.file_not_found) + return + } launchLoadingJob(Dispatchers.Default) { val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m) checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } @@ -149,16 +136,23 @@ class DetailsViewModel( } } + fun removeBookmark(bookmark: Bookmark) { + launchJob { + bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId) + onShowToast.call(R.string.bookmark_removed) + } + } + fun setChaptersReversed(newValue: Boolean) { settings.chaptersReverse = newValue } fun setSelectedBranch(branch: String?) { - selectedBranch.value = branch + delegate.selectedBranch.value = branch } fun getRemoteManga(): Manga? { - return relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL } + return delegate.relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL } } fun performChapterSearch(query: String?) { @@ -166,7 +160,7 @@ class DetailsViewModel( } fun onDownloadComplete(downloadedManga: Manga) { - val currentManga = mangaData.value ?: return + val currentManga = delegate.manga.value ?: return if (currentManga.id != downloadedManga.id) { return } @@ -177,142 +171,16 @@ class DetailsViewModel( runCatching { localMangaRepository.getDetails(downloadedManga) }.onSuccess { - relatedManga.value = it + delegate.relatedManga.value = it }.onFailure { - if (BuildConfig.DEBUG) { - it.printStackTrace() - } + it.printStackTraceDebug() } } } } private fun doLoad() = launchLoadingJob(Dispatchers.Default) { - var manga = mangaDataRepository.resolveIntent(intent) - ?: throw MangaNotFoundException("Cannot find manga") - mangaData.value = manga - manga = MangaRepository(manga.source).getDetails(manga) - // find default branch - val hist = historyRepository.getOne(manga) - selectedBranch.value = if (hist != null) { - val currentChapter = manga.chapters?.find { it.id == hist.chapterId } - if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters) - } else { - predictBranch(manga.chapters) - } - mangaData.value = manga - relatedManga.value = runCatching { - if (manga.source == MangaSource.LOCAL) { - val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null - MangaRepository(m.source).getDetails(m) - } else { - localMangaRepository.findSavedManga(manga) - } - }.onFailure { error -> - if (BuildConfig.DEBUG) error.printStackTrace() - }.getOrNull() - } - - private fun mapChapters( - chapters: List, - downloadedChapters: List?, - currentId: Long?, - newCount: Int, - branch: String?, - ): List { - val result = ArrayList(chapters.size) - val dateFormat = settings.getDateFormat() - val currentIndex = chapters.indexOfFirst { it.id == currentId } - val firstNewIndex = chapters.size - newCount - val downloadedIds = downloadedChapters?.mapToSet { it.id } - for (i in chapters.indices) { - val chapter = chapters[i] - if (chapter.branch != branch) { - continue - } - result += chapter.toListItem( - isCurrent = i == currentIndex, - isUnread = i > currentIndex, - isNew = i >= firstNewIndex, - isMissing = false, - isDownloaded = downloadedIds?.contains(chapter.id) == true, - dateFormat = dateFormat, - ) - } - return result - } - - private fun mapChaptersWithSource( - chapters: List, - sourceChapters: List, - currentId: Long?, - newCount: Int, - branch: String?, - ): List { - val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id } - val result = ArrayList(sourceChapters.size) - val currentIndex = sourceChapters.indexOfFirst { it.id == currentId } - val firstNewIndex = sourceChapters.size - newCount - val dateFormat = settings.getDateFormat() - for (i in sourceChapters.indices) { - val chapter = sourceChapters[i] - val localChapter = chaptersMap.remove(chapter.id) - if (chapter.branch != branch) { - continue - } - result += localChapter?.toListItem( - isCurrent = i == currentIndex, - isUnread = i > currentIndex, - isNew = i >= firstNewIndex, - isMissing = false, - isDownloaded = false, - dateFormat = dateFormat, - ) ?: chapter.toListItem( - isCurrent = i == currentIndex, - isUnread = i > currentIndex, - isNew = i >= firstNewIndex, - isMissing = true, - isDownloaded = false, - dateFormat = dateFormat, - ) - } - if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source - result.ensureCapacity(result.size + chaptersMap.size) - chaptersMap.values.mapNotNullTo(result) { - if (it.branch == branch) { - it.toListItem( - isCurrent = false, - isUnread = true, - isNew = false, - isMissing = false, - isDownloaded = false, - dateFormat = dateFormat, - ) - } else { - null - } - } - result.sortBy { it.chapter.number } - } - return result - } - - private fun predictBranch(chapters: List?): String? { - if (chapters.isNullOrEmpty()) { - return null - } - val groups = chapters.groupBy { it.branch } - for (locale in LocaleListCompat.getAdjustedDefault()) { - var language = locale.getDisplayLanguage(locale).toTitleCase(locale) - if (groups.containsKey(language)) { - return language - } - language = locale.getDisplayName(locale).toTitleCase(locale) - if (groups.containsKey(language)) { - return language - } - } - return groups.maxByOrNull { it.value.size }?.key + delegate.doLoad() } private fun List.filterSearch(query: String): List { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt new file mode 100644 index 000000000..07f03dbda --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt @@ -0,0 +1,184 @@ +package org.koitharu.kotatsu.details.ui + +import androidx.core.os.LocaleListCompat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException +import org.koitharu.kotatsu.core.model.MangaHistory +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.details.ui.model.ChapterListItem +import org.koitharu.kotatsu.details.ui.model.toListItem +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +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.toTitleCase +import org.koitharu.kotatsu.utils.ext.iterator +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug + +class MangaDetailsDelegate( + private val intent: MangaIntent, + private val settings: AppSettings, + private val mangaDataRepository: MangaDataRepository, + private val historyRepository: HistoryRepository, + private val localMangaRepository: LocalMangaRepository, +) { + + private val mangaData = MutableStateFlow(intent.manga) + + val selectedBranch = MutableStateFlow(null) + // Remote manga for saved and saved for remote + val relatedManga = MutableStateFlow(null) + val manga: StateFlow + get() = mangaData + val mangaId = intent.manga?.id ?: intent.mangaId + + suspend fun doLoad() { + var manga = mangaDataRepository.resolveIntent(intent) + ?: throw MangaNotFoundException("Cannot find manga") + mangaData.value = manga + manga = MangaRepository(manga.source).getDetails(manga) + // find default branch + val hist = historyRepository.getOne(manga) + selectedBranch.value = if (hist != null) { + val currentChapter = manga.chapters?.find { it.id == hist.chapterId } + if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters) + } else { + predictBranch(manga.chapters) + } + mangaData.value = manga + relatedManga.value = runCatching { + if (manga.source == MangaSource.LOCAL) { + val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null + MangaRepository(m.source).getDetails(m) + } else { + localMangaRepository.findSavedManga(manga) + } + }.onFailure { error -> + error.printStackTraceDebug() + }.getOrNull() + } + + fun mapChapters( + manga: Manga?, + related: Manga?, + history: MangaHistory?, + newCount: Int, + branch: String?, + ): List { + val chapters = manga?.chapters ?: return emptyList() + val relatedChapters = related?.chapters + return if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) { + mapChaptersWithSource(chapters, relatedChapters, history?.chapterId, newCount, branch) + } else { + mapChapters(chapters, relatedChapters, history?.chapterId, newCount, branch) + } + } + + private fun mapChapters( + chapters: List, + downloadedChapters: List?, + currentId: Long?, + newCount: Int, + branch: String?, + ): List { + val result = ArrayList(chapters.size) + val dateFormat = settings.getDateFormat() + val currentIndex = chapters.indexOfFirst { it.id == currentId } + val firstNewIndex = chapters.size - newCount + val downloadedIds = downloadedChapters?.mapToSet { it.id } + for (i in chapters.indices) { + val chapter = chapters[i] + if (chapter.branch != branch) { + continue + } + result += chapter.toListItem( + isCurrent = i == currentIndex, + isUnread = i > currentIndex, + isNew = i >= firstNewIndex, + isMissing = false, + isDownloaded = downloadedIds?.contains(chapter.id) == true, + dateFormat = dateFormat, + ) + } + return result + } + + private fun mapChaptersWithSource( + chapters: List, + sourceChapters: List, + currentId: Long?, + newCount: Int, + branch: String?, + ): List { + val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id } + val result = ArrayList(sourceChapters.size) + val currentIndex = sourceChapters.indexOfFirst { it.id == currentId } + val firstNewIndex = sourceChapters.size - newCount + val dateFormat = settings.getDateFormat() + for (i in sourceChapters.indices) { + val chapter = sourceChapters[i] + val localChapter = chaptersMap.remove(chapter.id) + if (chapter.branch != branch) { + continue + } + result += localChapter?.toListItem( + isCurrent = i == currentIndex, + isUnread = i > currentIndex, + isNew = i >= firstNewIndex, + isMissing = false, + isDownloaded = false, + dateFormat = dateFormat, + ) ?: chapter.toListItem( + isCurrent = i == currentIndex, + isUnread = i > currentIndex, + isNew = i >= firstNewIndex, + isMissing = true, + isDownloaded = false, + dateFormat = dateFormat, + ) + } + if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source + result.ensureCapacity(result.size + chaptersMap.size) + chaptersMap.values.mapNotNullTo(result) { + if (it.branch == branch) { + it.toListItem( + isCurrent = false, + isUnread = true, + isNew = false, + isMissing = false, + isDownloaded = false, + dateFormat = dateFormat, + ) + } else { + null + } + } + result.sortBy { it.chapter.number } + } + return result + } + + private fun predictBranch(chapters: List?): String? { + if (chapters.isNullOrEmpty()) { + return null + } + val groups = chapters.groupBy { it.branch } + for (locale in LocaleListCompat.getAdjustedDefault()) { + var language = locale.getDisplayLanguage(locale).toTitleCase(locale) + if (groups.containsKey(language)) { + return language + } + language = locale.getDisplayName(locale).toTitleCase(locale) + if (groups.containsKey(language)) { + return language + } + } + return groups.maxByOrNull { it.value.size }?.key + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt index 9a423b2e2..f65951d47 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt @@ -1,9 +1,9 @@ package org.koitharu.kotatsu.details.ui.adapter -import android.view.View import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.details.ui.model.ChapterListItem @@ -21,11 +21,7 @@ fun chapterListItemAD( { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) } ) { - val eventListener = object : View.OnClickListener, View.OnLongClickListener { - override fun onClick(v: View) = clickListener.onItemClick(item, v) - override fun onLongClick(v: View) = clickListener.onItemLongClick(item, v) - } - + val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener) itemView.setOnClickListener(eventListener) itemView.setOnLongClickListener(eventListener) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index 58335ed31..d079eb51f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.sync.Semaphore import okhttp3.OkHttpClient import okhttp3.Request import okio.IOException -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository @@ -24,6 +23,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.utils.ext.deleteAwait +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.waitForNetwork import org.koitharu.kotatsu.utils.progress.ProgressJob @@ -156,9 +156,7 @@ class DownloadManager( outState.value = DownloadState.Cancelled(startId, manga, cover) throw e } catch (e: Throwable) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } + e.printStackTraceDebug() outState.value = DownloadState.Error(startId, manga, cover, e) } finally { withContext(NonCancellable) { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt index e249e4dc5..a0c6c63dd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt @@ -3,10 +3,8 @@ package org.koitharu.kotatsu.download.ui import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.flatMapLatest @@ -17,7 +15,7 @@ import org.koin.android.ext.android.get import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.download.ui.service.DownloadService -import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection +import org.koitharu.kotatsu.utils.bindServiceWithLifecycle class DownloadsActivity : BaseActivity() { @@ -28,11 +26,10 @@ class DownloadsActivity : BaseActivity() { val adapter = DownloadsAdapter(lifecycleScope, get()) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.adapter = adapter - LifecycleAwareServiceConnection.bindService( - this, - this, - Intent(this, DownloadService::class.java), - 0 + bindServiceWithLifecycle( + owner = this, + service = Intent(this, DownloadService::class.java), + flags = 0, ).service.flatMapLatest { binder -> (binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null) }.onEach { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt index 528908bfb..a424e7086 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -59,6 +59,13 @@ class DownloadNotification(private val context: Context, startId: Int) { builder.setStyle(null) builder.setLargeIcon(state.cover?.toBitmap()) builder.clearActions() + builder.setVisibility( + if (state.manga.isNsfw) { + NotificationCompat.VISIBILITY_PRIVATE + } else { + NotificationCompat.VISIBILITY_PUBLIC + } + ) when (state) { is DownloadState.Cancelled -> { builder.setProgress(1, 0, true) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 05c6df6bd..91c742e73 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -99,39 +99,42 @@ class DownloadService : BaseService() { private fun listenJob(job: ProgressJob) { lifecycleScope.launch { val startId = job.progressValue.startId - val timeLeftEstimator = TimeLeftEstimator() val notification = DownloadNotification(this@DownloadService, startId) - notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L)) - job.progressAsFlow() - .onEach { state -> - if (state is DownloadState.Progress) { - timeLeftEstimator.tick(value = state.progress, total = state.max) - } else { - timeLeftEstimator.emptyTick() + try { + val timeLeftEstimator = TimeLeftEstimator() + notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L)) + job.progressAsFlow() + .onEach { state -> + if (state is DownloadState.Progress) { + timeLeftEstimator.tick(value = state.progress, total = state.max) + } else { + timeLeftEstimator.emptyTick() + } } + .throttle { state -> if (state is DownloadState.Progress) 400L else 0L } + .whileActive() + .collect { state -> + val timeLeft = timeLeftEstimator.getEstimatedTimeLeft() + notificationSwitcher.notify(startId, notification.create(state, timeLeft)) + } + job.join() + } finally { + (job.progressValue as? DownloadState.Done)?.let { + sendBroadcast( + Intent(ACTION_DOWNLOAD_COMPLETE) + .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)) + ) } - .throttle { state -> if (state is DownloadState.Progress) 400L else 0L } - .whileActive() - .collect { state -> - val timeLeft = timeLeftEstimator.getEstimatedTimeLeft() - notificationSwitcher.notify(startId, notification.create(state, timeLeft)) - } - job.join() - (job.progressValue as? DownloadState.Done)?.let { - sendBroadcast( - Intent(ACTION_DOWNLOAD_COMPLETE) - .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)) + notificationSwitcher.detach( + startId, + if (job.isCancelled) { + null + } else { + notification.create(job.progressValue, -1L) + } ) + stopSelf(startId) } - notificationSwitcher.detach( - startId, - if (job.isCancelled) { - null - } else { - notification.create(job.progressValue, -1L) - } - ) - stopSelf(startId) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt index 8222c0f2b..6d880abb9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt @@ -4,13 +4,14 @@ import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel +import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditViewModel import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel val favouritesModule get() = module { - single { FavouritesRepository(get()) } + factory { FavouritesRepository(get(), get()) } viewModel { categoryId -> FavouritesListViewModel(categoryId.get(), get(), get(), get()) @@ -19,4 +20,5 @@ val favouritesModule viewModel { manga -> MangaCategoriesViewModel(manga.get(), get()) } + viewModel { params -> FavouritesCategoryEditViewModel(params[0], get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt index 801f2566a..c6a65c78e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt @@ -11,4 +11,5 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) sortKey = sortKey, order = SortOrder(order, SortOrder.NEWEST), createdAt = Date(createdAt), + isTrackingEnabled = track, ) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt index 436dc12ea..148dfd820 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt @@ -6,6 +6,9 @@ import kotlinx.coroutines.flow.Flow @Dao abstract class FavouriteCategoriesDao { + @Query("SELECT * FROM favourite_categories WHERE category_id = :id") + abstract suspend fun find(id: Int): FavouriteCategoryEntity + @Query("SELECT * FROM favourite_categories ORDER BY sort_key") abstract suspend fun findAll(): List @@ -13,7 +16,7 @@ abstract class FavouriteCategoriesDao { abstract fun observeAll(): Flow> @Query("SELECT * FROM favourite_categories WHERE category_id = :id") - abstract fun observe(id: Long): Flow + abstract fun observe(id: Long): Flow @Insert(onConflict = OnConflictStrategy.ABORT) abstract suspend fun insert(category: FavouriteCategoryEntity): Long @@ -27,9 +30,15 @@ abstract class FavouriteCategoriesDao { @Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id") abstract suspend fun updateTitle(id: Long, title: String) + @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id") + abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean) + @Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id") abstract suspend fun updateOrder(id: Long, order: String) + @Query("UPDATE favourite_categories SET `track` = :isEnabled WHERE category_id = :id") + abstract suspend fun updateTracking(id: Long, isEnabled: Boolean) + @Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id") abstract suspend fun updateSortKey(id: Long, sortKey: Int) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt index 317a67162..08cbd7b62 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt @@ -3,7 +3,7 @@ package org.koitharu.kotatsu.favourites.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITE_CATEGORIES +import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES @Entity(tableName = TABLE_FAVOURITE_CATEGORIES) class FavouriteCategoryEntity( @@ -13,4 +13,5 @@ class FavouriteCategoryEntity( @ColumnInfo(name = "sort_key") val sortKey: Int, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "order") val order: String, + @ColumnInfo(name = "track") val track: Boolean, ) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt index a423adc33..a13e54230 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt @@ -3,11 +3,12 @@ package org.koitharu.kotatsu.favourites.data import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITES +import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.entity.MangaEntity @Entity( - tableName = TABLE_FAVOURITES, primaryKeys = ["manga_id", "category_id"], + tableName = TABLE_FAVOURITES, + primaryKeys = ["manga_id", "category_id"], foreignKeys = [ ForeignKey( entity = MangaEntity::class, diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 9e5da45f7..89fcc92fb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -43,6 +43,9 @@ abstract class FavouritesDao { @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset") abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List + @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE category_id = :categoryId)") + abstract suspend fun findAllManga(categoryId: Int): List + @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)") abstract suspend fun findAllManga(): List diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 731d7ff5e..13a4c92c4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -1,10 +1,7 @@ package org.koitharu.kotatsu.favourites.domain import androidx.room.withTransaction -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.* import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.model.FavouriteCategory @@ -13,9 +10,13 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.utils.ext.mapItems -class FavouritesRepository(private val db: MangaDatabase) { +class FavouritesRepository( + private val db: MangaDatabase, + private val channels: TrackerNotificationChannels, +) { suspend fun getAllManga(): List { val entities = db.favouritesDao.findAll() @@ -48,6 +49,11 @@ class FavouritesRepository(private val db: MangaDatabase) { }.distinctUntilChanged() } + fun observeCategory(id: Long): Flow { + return db.favouriteCategoriesDao.observe(id) + .map { it?.toFavouriteCategory() } + } + fun observeCategories(mangaId: Long): Flow> { return db.favouritesDao.observe(mangaId).map { entity -> entity?.categories?.map { it.toFavouriteCategory() }.orEmpty() @@ -58,6 +64,29 @@ class FavouritesRepository(private val db: MangaDatabase) { return db.favouritesDao.observeIds(mangaId).map { it.toSet() } } + suspend fun getCategory(id: Long): FavouriteCategory { + return db.favouriteCategoriesDao.find(id.toInt()).toFavouriteCategory() + } + + suspend fun createCategory(title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean): FavouriteCategory { + val entity = FavouriteCategoryEntity( + title = title, + createdAt = System.currentTimeMillis(), + sortKey = db.favouriteCategoriesDao.getNextSortKey(), + categoryId = 0, + order = sortOrder.name, + track = isTrackerEnabled, + ) + val id = db.favouriteCategoriesDao.insert(entity) + val category = entity.toFavouriteCategory(id) + channels.createChannel(category) + return category + } + + suspend fun updateCategory(id: Long, title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean) { + db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled) + } + suspend fun addCategory(title: String): FavouriteCategory { val entity = FavouriteCategoryEntity( title = title, @@ -65,23 +94,32 @@ class FavouritesRepository(private val db: MangaDatabase) { sortKey = db.favouriteCategoriesDao.getNextSortKey(), categoryId = 0, order = SortOrder.NEWEST.name, + track = true, ) val id = db.favouriteCategoriesDao.insert(entity) - return entity.toFavouriteCategory(id) + val category = entity.toFavouriteCategory(id) + channels.createChannel(category) + return category } suspend fun renameCategory(id: Long, title: String) { db.favouriteCategoriesDao.updateTitle(id, title) + channels.renameChannel(id, title) } suspend fun removeCategory(id: Long) { db.favouriteCategoriesDao.delete(id) + channels.deleteChannel(id) } suspend fun setCategoryOrder(id: Long, order: SortOrder) { db.favouriteCategoriesDao.updateOrder(id, order.name) } + suspend fun setCategoryTracking(id: Long, isEnabled: Boolean) { + db.favouriteCategoriesDao.updateTracking(id, isEnabled) + } + suspend fun reorderCategories(orderedIds: List) { val dao = db.favouriteCategoriesDao db.withTransaction { @@ -121,6 +159,7 @@ class FavouritesRepository(private val db: MangaDatabase) { private fun observeOrder(categoryId: Long): Flow { return db.favouriteCategoriesDao.observe(categoryId) + .filterNotNull() .map { x -> SortOrder(x.order, SortOrder.NEWEST) } .distinctUntilChanged() } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt index 4433f978d..8e9d945a0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt @@ -6,6 +6,7 @@ import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.PopupMenu import androidx.core.graphics.Insets import androidx.core.view.children +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import com.google.android.material.snackbar.Snackbar @@ -16,12 +17,13 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.util.ActionModeListener import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.titleRes import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding +import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel +import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.measureHeight @@ -31,13 +33,15 @@ class FavouritesContainerFragment : BaseFragment(), FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback, - ActionModeListener { + ActionModeListener, + View.OnClickListener { private val viewModel by viewModel() private val editDelegate by lazy(LazyThreadSafetyMode.NONE) { CategoriesEditDelegate(requireContext(), this) } private var pagerAdapter: FavouritesPagerAdapter? = null + private var stubBinding: ItemEmptyStateBinding? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -52,9 +56,7 @@ class FavouritesContainerFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val adapter = FavouritesPagerAdapter(this, this) - viewModel.visibleCategories.value?.let { - adapter.replaceData(it) - } + viewModel.visibleCategories.value?.let(::onCategoriesChanged) binding.pager.adapter = adapter pagerAdapter = adapter TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() @@ -66,6 +68,7 @@ class FavouritesContainerFragment : override fun onDestroyView() { pagerAdapter = null + stubBinding = null super.onDestroyView() } @@ -101,6 +104,15 @@ class FavouritesContainerFragment : private fun onCategoriesChanged(categories: List) { pagerAdapter?.replaceData(categories) + if (categories.isEmpty()) { + binding.pager.isVisible = false + binding.tabs.isVisible = false + showStub() + } else { + binding.pager.isVisible = true + binding.tabs.isVisible = true + (stubBinding?.root ?: binding.stubEmptyState).isVisible = false + } } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -130,28 +142,16 @@ class FavouritesContainerFragment : return true } + override fun onClick(v: View) { + when (v.id) { + R.id.button_retry -> startActivity(FavouritesCategoryEditActivity.newIntent(v.context)) + } + } + override fun onDeleteCategory(category: FavouriteCategory) { viewModel.deleteCategory(category.id) } - override fun onRenameCategory(category: FavouriteCategory, newName: String) { - viewModel.renameCategory(category.id, newName) - } - - override fun onCreateCategory(name: String) { - viewModel.createCategory(name) - } - - private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) { - val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return - for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) { - val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes) - menuItem.isCheckable = true - menuItem.isChecked = item == category.order - } - submenu.setGroupCheckable(R.id.group_order, true, true) - } - private fun TabLayout.setTabsEnabled(enabled: Boolean) { val tabStrip = getChildAt(0) as? ViewGroup ?: return for (tab in tabStrip.children) { @@ -162,18 +162,11 @@ class FavouritesContainerFragment : private fun showCategoryMenu(tabView: View, category: FavouriteCategory) { val menu = PopupMenu(tabView.context, tabView) menu.inflate(R.menu.popup_category) - createOrderSubmenu(menu.menu, category) menu.setOnMenuItemClickListener { when (it.itemId) { R.id.action_remove -> editDelegate.deleteCategory(category) - R.id.action_rename -> editDelegate.renameCategory(category) - R.id.action_create -> editDelegate.createCategory() - R.id.action_order -> return@setOnMenuItemClickListener false - else -> { - val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order) - ?: return@setOnMenuItemClickListener false - viewModel.setCategoryOrder(category.id, order) - } + R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(tabView.context, category.id)) + else -> return@setOnMenuItemClickListener false } true } @@ -185,7 +178,7 @@ class FavouritesContainerFragment : menu.inflate(R.menu.popup_category_all) menu.setOnMenuItemClickListener { when (it.itemId) { - R.id.action_create -> editDelegate.createCategory() + R.id.action_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext())) R.id.action_hide -> viewModel.setAllCategoriesVisible(false) } true @@ -193,6 +186,18 @@ class FavouritesContainerFragment : menu.show() } + private fun showStub() { + val stub = stubBinding ?: ItemEmptyStateBinding.bind(binding.stubEmptyState.inflate()) + stub.root.isVisible = true + stub.icon.setImageResource(R.drawable.ic_heart_outline) + stub.textPrimary.setText(R.string.text_empty_holder_primary) + stub.textSecondary.setText(R.string.empty_favourite_categories) + stub.buttonRetry.setText(R.string.add) + stub.buttonRetry.isVisible = true + stub.buttonRetry.setOnClickListener(this) + stubBinding = stub + } + companion object { fun newInstance() = FavouritesContainerFragment() diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt index 5a2eaf8df..80fd2a137 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt @@ -3,7 +3,6 @@ package org.koitharu.kotatsu.favourites.ui.categories import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.Menu import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu @@ -19,9 +18,9 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.titleRes import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel +import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.measureHeight @@ -30,7 +29,8 @@ class CategoriesActivity : BaseActivity(), OnListItemClickListener, View.OnClickListener, - CategoriesEditDelegate.CategoriesEditCallback, AllCategoriesToggleListener { + CategoriesEditDelegate.CategoriesEditCallback, + AllCategoriesToggleListener { private val viewModel by viewModel() @@ -56,23 +56,17 @@ class CategoriesActivity : override fun onClick(v: View) { when (v.id) { - R.id.fab_add -> editDelegate.createCategory() + R.id.fab_add -> startActivity(FavouritesCategoryEditActivity.newIntent(this)) } } override fun onItemClick(item: FavouriteCategory, view: View) { val menu = PopupMenu(view.context, view) menu.inflate(R.menu.popup_category) - createOrderSubmenu(menu.menu, item) menu.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.action_remove -> editDelegate.deleteCategory(item) - R.id.action_rename -> editDelegate.renameCategory(item) - R.id.action_order -> return@setOnMenuItemClickListener false - else -> { - val order = SORT_ORDERS.getOrNull(menuItem.order) ?: return@setOnMenuItemClickListener false - viewModel.setCategoryOrder(item.id, order) - } + R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(this, item.id)) } true } @@ -116,29 +110,6 @@ class CategoriesActivity : viewModel.deleteCategory(category.id) } - override fun onRenameCategory(category: FavouriteCategory, newName: String) { - viewModel.renameCategory(category.id, newName) - } - - override fun onCreateCategory(name: String) { - viewModel.createCategory(name) - } - - private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) { - val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return - for ((i, item) in SORT_ORDERS.withIndex()) { - val menuItem = submenu.add( - R.id.group_order, - Menu.NONE, - i, - item.titleRes - ) - menuItem.isCheckable = true - menuItem.isChecked = item == category.order - } - submenu.setGroupCheckable(R.id.group_order, true, true) - } - private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback( ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0 ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt index e13b31e00..7a5620158 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt @@ -40,7 +40,10 @@ class CategoriesAdapter( newItem: CategoryListModel, ): Any? = when { oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit - else -> super.getChangePayload(oldItem, newItem) + oldItem is CategoryListModel.CategoryItem && + newItem is CategoryListModel.CategoryItem && + oldItem.category.title != newItem.category.title -> null + else -> Unit } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt index 603a70ee3..f7a98c078 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt @@ -1,15 +1,10 @@ package org.koitharu.kotatsu.favourites.ui.categories import android.content.Context -import android.text.InputType -import android.widget.Toast import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog import org.koitharu.kotatsu.core.model.FavouriteCategory -private const val MAX_TITLE_LENGTH = 24 - class CategoriesEditDelegate( private val context: Context, private val callback: CategoriesEditCallback @@ -26,49 +21,8 @@ class CategoriesEditDelegate( .show() } - fun renameCategory(category: FavouriteCategory) { - TextInputDialog.Builder(context) - .setTitle(R.string.rename) - .setText(category.title) - .setHint(R.string.enter_category_name) - .setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) - .setNegativeButton(android.R.string.cancel) - .setMaxLength(MAX_TITLE_LENGTH, false) - .setPositiveButton(R.string.rename) { _, name -> - val trimmed = name.trim() - if (trimmed.isEmpty()) { - Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show() - } else { - callback.onRenameCategory(category, name) - } - }.create() - .show() - } - - fun createCategory() { - TextInputDialog.Builder(context) - .setTitle(R.string.add_new_category) - .setHint(R.string.enter_category_name) - .setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) - .setNegativeButton(android.R.string.cancel) - .setMaxLength(MAX_TITLE_LENGTH, false) - .setPositiveButton(R.string.add) { _, name -> - val trimmed = name.trim() - if (trimmed.isEmpty()) { - Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show() - } else { - callback.onCreateCategory(trimmed) - } - }.create() - .show() - } - interface CategoriesEditCallback { fun onDeleteCategory(category: FavouriteCategory) - - fun onRenameCategory(category: FavouriteCategory, newName: String) - - fun onCreateCategory(name: String) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt index 7aac74e62..1e24d033f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt @@ -3,13 +3,13 @@ package org.koitharu.kotatsu.favourites.ui.categories import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel -import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import java.util.* @@ -31,33 +31,15 @@ class FavouritesCategoriesViewModel( repository.observeCategories(), observeAllCategoriesVisible(), ) { list, showAll -> - mapCategories(list, showAll, showAll) + mapCategories(list, showAll, showAll && list.isNotEmpty()) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) - fun createCategory(name: String) { - launchJob { - repository.addCategory(name) - } - } - - fun renameCategory(id: Long, name: String) { - launchJob { - repository.renameCategory(id, name) - } - } - fun deleteCategory(id: Long) { launchJob { repository.removeCategory(id) } } - fun setCategoryOrder(id: Long, order: SortOrder) { - launchJob { - repository.setCategoryOrder(id, order) - } - } - fun setAllCategoriesVisible(isVisible: Boolean) { settings.isAllFavouritesVisible = isVisible } @@ -89,9 +71,7 @@ class FavouritesCategoriesViewModel( return result } - private fun observeAllCategoriesVisible() = settings.observe() - .filter { it == AppSettings.KEY_ALL_FAVOURITES_VISIBLE } - .map { settings.isAllFavouritesVisible } - .onStart { emit(settings.isAllFavouritesVisible) } - .distinctUntilChanged() + private fun observeAllCategoriesVisible() = settings.observeAsFlow(AppSettings.KEY_ALL_FAVOURITES_VISIBLE) { + isAllFavouritesVisible + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt index d840b783f..e64e36e5a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt @@ -16,7 +16,7 @@ fun categoryAD( clickListener.onItemClick(item.category, it) } @Suppress("ClickableViewAccessibility") - binding.imageViewHandle.setOnTouchListener { v, event -> + binding.imageViewHandle.setOnTouchListener { _, event -> if (event.actionMasked == MotionEvent.ACTION_DOWN) { clickListener.onItemLongClick(item.category, itemView) } else { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt index 8326f8617..899b73e1c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt @@ -45,6 +45,7 @@ sealed interface CategoryListModel : ListModel { if (category.id != other.category.id) return false if (category.title != other.category.title) return false if (category.order != other.category.order) return false + if (category.isTrackingEnabled != other.category.isTrackingEnabled) return false return true } @@ -53,6 +54,7 @@ sealed interface CategoryListModel : ListModel { var result = category.id.hashCode() result = 31 * result + category.title.hashCode() result = 31 * result + category.order.hashCode() + result = 31 * result + category.isTrackingEnabled.hashCode() return result } } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt new file mode 100644 index 000000000..27239c0dc --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt @@ -0,0 +1,147 @@ +package org.koitharu.kotatsu.favourites.ui.categories.edit + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.core.graphics.Insets +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.ui.titleRes +import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding +import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.utils.ext.getDisplayMessage + +class FavouritesCategoryEditActivity : BaseActivity(), AdapterView.OnItemClickListener { + + private val viewModel by viewModel { + parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID)) + } + private var selectedSortOrder: SortOrder? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityCategoryEditBinding.inflate(layoutInflater)) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material) + } + initSortSpinner() + + viewModel.onSaved.observe(this) { finishAfterTransition() } + viewModel.category.observe(this, ::onCategoryChanged) + viewModel.isLoading.observe(this, ::onLoadingStateChanged) + viewModel.onError.observe(this, ::onError) + viewModel.isTrackerEnabled.observe(this) { + binding.switchTracker.isVisible = it + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putSerializable(KEY_SORT_ORDER, selectedSortOrder) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + val order = savedInstanceState.getSerializable(KEY_SORT_ORDER) + if (order != null && order is SortOrder) { + selectedSortOrder = order + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.opt_config, menu) + menu.findItem(R.id.action_done)?.setTitle(R.string.save) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { + R.id.action_done -> { + viewModel.save( + title = binding.editName.text?.toString().orEmpty(), + sortOrder = getSelectedSortOrder(), + isTrackerEnabled = binding.switchTracker.isChecked, + ) + true + } + else -> super.onOptionsItemSelected(item) + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.scrollView.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom, + ) + binding.toolbar.updatePadding( + top = insets.top, + ) + } + + override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + selectedSortOrder = CategoriesActivity.SORT_ORDERS.getOrNull(position) + } + + private fun onCategoryChanged(category: FavouriteCategory?) { + setTitle(if (category == null) R.string.create_category else R.string.edit_category) + if (selectedSortOrder != null) { + return + } + binding.editName.setText(category?.title) + selectedSortOrder = category?.order + val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes) + binding.editSort.setText(sortText, false) + binding.switchTracker.isChecked = category?.isTrackingEnabled ?: true + } + + private fun onError(e: Throwable) { + binding.textViewError.text = e.getDisplayMessage(resources) + binding.textViewError.isVisible = true + } + + private fun onLoadingStateChanged(isLoading: Boolean) { + binding.editSort.isEnabled = !isLoading + binding.editName.isEnabled = !isLoading + binding.switchTracker.isEnabled = !isLoading + if (isLoading) { + binding.textViewError.isVisible = false + } + } + + private fun initSortSpinner() { + val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) } + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, entries) + binding.editSort.setAdapter(adapter) + binding.editSort.onItemClickListener = this + } + + private fun getSelectedSortOrder(): SortOrder { + selectedSortOrder?.let { return it } + val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) } + val index = entries.indexOf(binding.editSort.text.toString()) + return CategoriesActivity.SORT_ORDERS.getOrNull(index) ?: SortOrder.NEWEST + } + + companion object { + + private const val EXTRA_ID = "id" + private const val KEY_SORT_ORDER = "sort" + private const val NO_ID = -1L + + fun newIntent(context: Context, id: Long = NO_ID): Intent { + return Intent(context, FavouritesCategoryEditActivity::class.java) + .putExtra(EXTRA_ID, id) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt new file mode 100644 index 000000000..78446dd14 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt @@ -0,0 +1,53 @@ +package org.koitharu.kotatsu.favourites.ui.categories.edit + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.utils.SingleLiveEvent + +private const val NO_ID = -1L + +class FavouritesCategoryEditViewModel( + private val categoryId: Long, + private val repository: FavouritesRepository, + private val settings: AppSettings, +) : BaseViewModel() { + + val onSaved = SingleLiveEvent() + val category = MutableLiveData() + + val isTrackerEnabled = liveData(viewModelScope.coroutineContext + Dispatchers.Default) { + emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources) + } + + init { + launchLoadingJob { + category.value = if (categoryId != NO_ID) { + repository.getCategory(categoryId) + } else { + null + } + } + } + + fun save( + title: String, + sortOrder: SortOrder, + isTrackerEnabled: Boolean, + ) { + launchLoadingJob { + if (categoryId == NO_ID) { + repository.createCategory(title, sortOrder, isTrackerEnabled) + } else { + repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled) + } + onSaved.call(Unit) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt similarity index 86% rename from app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt rename to app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt index 43eec185e..aa2bacbbe 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt @@ -17,26 +17,24 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate +import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.withArgs -class FavouriteCategoriesDialog : +class FavouriteCategoriesBottomSheet : BaseBottomSheet(), OnListItemClickListener, CategoriesEditDelegate.CategoriesEditCallback, - Toolbar.OnMenuItemClickListener { + Toolbar.OnMenuItemClickListener, View.OnClickListener { private val viewModel by viewModel { parametersOf(requireNotNull(arguments?.getParcelableArrayList(KEY_MANGA_LIST)).map { it.manga }) } private var adapter: MangaCategoriesAdapter? = null - private val editDelegate by lazy(LazyThreadSafetyMode.NONE) { - CategoriesEditDelegate(requireContext(), this@FavouriteCategoriesDialog) - } override fun onInflateView( inflater: LayoutInflater, @@ -48,6 +46,7 @@ class FavouriteCategoriesDialog : adapter = MangaCategoriesAdapter(this) binding.recyclerViewCategories.adapter = adapter binding.toolbar.setOnMenuItemClickListener(this) + binding.itemCreate.setOnClickListener(this) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.onError.observe(viewLifecycleOwner, ::onError) @@ -60,26 +59,26 @@ class FavouriteCategoriesDialog : override fun onMenuItemClick(item: MenuItem): Boolean { return when (item.itemId) { - R.id.action_create -> { - editDelegate.createCategory() + R.id.action_done -> { + dismiss() true } else -> false } } + override fun onClick(v: View) { + when (v.id) { + R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext())) + } + } + override fun onItemClick(item: MangaCategoryItem, view: View) { viewModel.setChecked(item.id, !item.isChecked) } override fun onDeleteCategory(category: FavouriteCategory) = Unit - override fun onRenameCategory(category: FavouriteCategory, newName: String) = Unit - - override fun onCreateCategory(name: String) { - viewModel.createCategory(name) - } - private fun onContentChanged(categories: List) { adapter?.items = categories } @@ -95,7 +94,7 @@ class FavouriteCategoriesDialog : fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga)) - fun show(fm: FragmentManager, manga: Collection) = FavouriteCategoriesDialog().withArgs(1) { + fun show(fm: FragmentManager, manga: Collection) = FavouriteCategoriesBottomSheet().withArgs(1) { putParcelableArrayList( KEY_MANGA_LIST, manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt index 84f9cf8f9..b9f906549 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt @@ -38,12 +38,6 @@ class MangaCategoriesViewModel( } } - fun createCategory(name: String) { - launchJob(Dispatchers.Default) { - favouritesRepository.addCategory(name) - } - } - private fun observeCategoriesIds() = if (manga.size == 1) { // Fast path favouritesRepository.observeCategoriesIds(manga[0].id) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCaegoryAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt similarity index 100% rename from app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCaegoryAD.kt rename to app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt index f2748622c..8d4b9e419 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -1,11 +1,17 @@ package org.koitharu.kotatsu.favourites.ui.list +import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem +import android.view.View import androidx.appcompat.view.ActionMode +import androidx.core.view.iterator import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.titleRes +import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.utils.ext.withArgs @@ -17,12 +23,54 @@ class FavouritesListFragment : MangaListFragment() { } private val categoryId: Long - get() = arguments?.getLong(ARG_CATEGORY_ID) ?: 0L + get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID override val isSwipeRefreshEnabled = false + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() } + } + override fun onScrolledToEnd() = Unit + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + if (categoryId != NO_ID) { + inflater.inflate(R.menu.opt_favourites_list, menu) + menu.findItem(R.id.action_order)?.subMenu?.let { submenu -> + for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) { + val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes) + menuItem.isCheckable = true + } + submenu.setGroupCheckable(R.id.group_order, true, true) + } + } + } + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.action_order)?.subMenu?.let { submenu -> + val selectedOrder = viewModel.sortOrder.value + for (item in submenu) { + val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order) + item.isChecked = order == selectedOrder + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when { + item.itemId == R.id.action_order -> false + item.groupId == R.id.group_order -> { + val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order) ?: return false + viewModel.setSortOrder(order) + true + } + else -> super.onOptionsItemSelected(item) + } + } + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { mode.menuInflater.inflate(R.menu.mode_favourites, menu) return super.onCreateActionMode(mode, menu) @@ -48,6 +96,7 @@ class FavouritesListFragment : MangaListFragment() { companion object { + const val NO_ID = 0L private const val ARG_CATEGORY_ID = "category_id" fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index 476fa6fb1..c2e8c00a1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -1,12 +1,16 @@ package org.koitharu.kotatsu.favourites.ui.list +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.list.domain.CountersProvider import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState @@ -24,8 +28,16 @@ class FavouritesListViewModel( settings: AppSettings, ) : MangaListViewModel(settings), CountersProvider { + var sortOrder: LiveData = if (categoryId == NO_ID) { + MutableLiveData(null) + } else { + repository.observeCategory(categoryId) + .map { it?.order } + .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + } + override val content = combine( - if (categoryId == 0L) { + if (categoryId == NO_ID) { repository.observeAll(SortOrder.NEWEST) } else { repository.observeAll(categoryId) @@ -37,7 +49,7 @@ class FavouritesListViewModel( EmptyState( icon = R.drawable.ic_heart_outline, textPrimary = R.string.text_empty_holder_primary, - textSecondary = if (categoryId == 0L) { + textSecondary = if (categoryId == NO_ID) { R.string.you_have_not_favourites_yet } else { R.string.favourites_category_empty @@ -60,7 +72,7 @@ class FavouritesListViewModel( return } launchJob { - if (categoryId == 0L) { + if (categoryId == NO_ID) { repository.removeFromFavourites(ids) } else { repository.removeFromCategory(categoryId, ids) @@ -68,6 +80,15 @@ class FavouritesListViewModel( } } + fun setSortOrder(order: SortOrder) { + if (categoryId == NO_ID) { + return + } + launchJob { + repository.setCategoryOrder(categoryId, order) + } + } + override suspend fun getCounter(mangaId: Long): Int { return trackingRepository.getNewChaptersCount(mangaId) } diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt index 74fb0ef40..246cb3a5f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt @@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel val historyModule get() = module { - single { HistoryRepository(get(), get(), get()) } + factory { HistoryRepository(get(), get(), get()) } viewModel { HistoryListViewModel(get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt index fd221d330..bfd1f9598 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -12,6 +12,10 @@ abstract class HistoryDao { @Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") abstract suspend fun findAll(offset: Int, limit: Int): List + @Transaction + @Query("SELECT * FROM history WHERE manga_id IN (:ids)") + abstract suspend fun findAll(ids: Collection): List + @Transaction @Query("SELECT * FROM history ORDER BY updated_at DESC") abstract fun observeAll(): Flow> @@ -66,4 +70,13 @@ abstract class HistoryDao { true } else false } + + @Transaction + open suspend fun upsert(entities: Iterable) { + for (e in entities) { + if (update(e) == 0) { + insert(e) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt index 36b12937c..95edf3d9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt @@ -4,7 +4,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY +import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.entity.MangaEntity @Entity( diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt index a88c8a82d..a4b2ab772 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -4,6 +4,7 @@ import androidx.room.withTransaction import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.base.domain.ReversibleHandle import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.model.MangaHistory @@ -100,6 +101,19 @@ class HistoryRepository( } } + suspend fun deleteReversible(ids: Collection): ReversibleHandle { + val entities = db.withTransaction { + val entities = db.historyDao.findAll(ids.toList()).filterNotNull() + for (id in ids) { + db.historyDao.delete(id) + } + entities + } + return ReversibleHandle { + db.historyDao.upsert(entities) + } + } + /** * Try to replace one manga with another one * Useful for replacing saved manga on deleting it with remove source diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index 6980b80ee..27f4a86ca 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -7,8 +7,11 @@ import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.ReversibleHandle +import org.koitharu.kotatsu.base.domain.reverseAsync import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.parsers.model.MangaSource @@ -22,6 +25,7 @@ class HistoryListFragment : MangaListFragment() { viewModel.isGroupingEnabled.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() } + viewModel.onItemsRemoved.observe(viewLifecycleOwner, ::onItemsRemoved) } override fun onScrolledToEnd() = Unit @@ -80,6 +84,12 @@ class HistoryListFragment : MangaListFragment() { } } + private fun onItemsRemoved(reversibleHandle: ReversibleHandle) { + Snackbar.make(binding.recyclerView, R.string.removed_from_history, Snackbar.LENGTH_LONG) + .setAction(R.string.undo) { reversibleHandle.reverseAsync() } + .show() + } + companion object { fun newInstance() = HistoryListFragment() diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 42dd81e95..1768c2a5f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -5,17 +5,24 @@ import androidx.lifecycle.viewModelScope import java.util.* import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.ReversibleHandle +import org.koitharu.kotatsu.base.domain.plus import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.onFirst @@ -28,12 +35,9 @@ class HistoryListViewModel( ) : MangaListViewModel(settings) { val isGroupingEnabled = MutableLiveData() + val onItemsRemoved = SingleLiveEvent() - private val historyGrouping = settings.observe() - .filter { it == AppSettings.KEY_HISTORY_GROUPING } - .map { settings.historyGrouping } - .onStart { emit(settings.historyGrouping) } - .distinctUntilChanged() + private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { historyGrouping } .onEach { isGroupingEnabled.postValue(it) } override val content = combine( @@ -52,8 +56,10 @@ class HistoryListViewModel( ) else -> mapList(list, grouped, mode) } + }.onStart { + loadingCounter.increment() }.onFirst { - isLoading.postValue(false) + loadingCounter.decrement() }.catch { it.toErrorState(canRetry = false) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) @@ -73,9 +79,12 @@ class HistoryListViewModel( if (ids.isEmpty()) { return } - launchJob { - repository.delete(ids) + launchJob(Dispatchers.Default) { + val handle = repository.deleteReversible(ids) + ReversibleHandle { + shortcutsRepository.updateShortcuts() + } shortcutsRepository.updateShortcuts() + onItemsRemoved.postCall(handle) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt index 8e674701a..28f58887b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt @@ -6,7 +6,6 @@ import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.view.ViewGroup -import androidx.appcompat.app.AppCompatDelegate import androidx.core.graphics.Insets import androidx.core.graphics.drawable.toBitmap import androidx.core.view.updateLayoutParams @@ -14,7 +13,7 @@ import androidx.core.view.updatePadding import coil.ImageLoader import coil.request.CachePolicy import coil.request.ImageRequest -import coil.target.PoolableViewTarget +import coil.target.ViewTarget import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koin.android.ext.android.inject @@ -61,16 +60,12 @@ class ImageActivity : BaseActivity() { private class SsivTarget( override val view: SubsamplingScaleImageView, - ) : PoolableViewTarget { - - override fun onStart(placeholder: Drawable?) = setDrawable(placeholder) + ) : ViewTarget { override fun onError(error: Drawable?) = setDrawable(error) override fun onSuccess(result: Drawable) = setDrawable(result) - override fun onClear() = setDrawable(null) - override fun equals(other: Any?): Boolean { return (this === other) || (other is SsivTarget && view == other.view) } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 4b8c2b570..ad7e3ab70 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -28,7 +28,7 @@ import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.ui.service.DownloadService -import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID import org.koitharu.kotatsu.list.ui.adapter.MangaListListener @@ -297,7 +297,7 @@ abstract class MangaListFragment : true } R.id.action_favourite -> { - FavouriteCategoriesDialog.show(childFragmentManager, selectedItems) + FavouriteCategoriesBottomSheet.show(childFragmentManager, selectedItems) mode.finish() true } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index 20f768c2f..6adc8c0d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -4,16 +4,14 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.onEach import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.MangaGridModel -import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel -import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct abstract class MangaListViewModel( private val settings: AppSettings, @@ -21,20 +19,15 @@ abstract class MangaListViewModel( abstract val content: LiveData> val listMode = MutableLiveData() - val gridScale = settings.observe() - .filter { it == AppSettings.KEY_GRID_SIZE } - .map { settings.gridSize / 100f } - .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.IO) { - settings.gridSize / 100f - } + val gridScale = settings.observeAsLiveData( + context = viewModelScope.coroutineContext + Dispatchers.Default, + key = AppSettings.KEY_GRID_SIZE, + valueProducer = { gridSize / 100f }, + ) open fun onRemoveFilterTag(tag: MangaTag) = Unit - protected fun createListModeFlow() = settings.observe() - .filter { it == AppSettings.KEY_LIST_MODE } - .map { settings.listMode } - .onStart { emit(settings.listMode) } - .distinctUntilChanged() + protected fun createListModeFlow() = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } .onEach { if (listMode.value != it) { listMode.postValue(it) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt index 86b72c738..c13fd3cfa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt @@ -13,7 +13,7 @@ fun currentFilterAD( val chipGroup = itemView as ChipsView - chipGroup.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { chip, data -> + chipGroup.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { _, data -> listener.onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener) } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt index ced1697b0..f1d6d3af4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import coil.request.Disposable +import coil.size.Scale import coil.util.CoilUtils import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding @@ -43,6 +44,7 @@ fun mangaGridItemAD( .fallback(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder) .allowRgb565(true) + .scale(Scale.FILL) .lifecycle(lifecycleOwner) .enqueueWith(coil) badge = itemView.bindBadge(badge, item.counter) @@ -53,7 +55,7 @@ fun mangaGridItemAD( badge = null imageRequest?.dispose() imageRequest = null - CoilUtils.clear(binding.imageViewCover) + CoilUtils.dispose(binding.imageViewCover) binding.imageViewCover.setImageDrawable(null) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt index 10e9a473d..74a67378f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import coil.request.Disposable +import coil.size.Scale import coil.util.CoilUtils import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding @@ -44,6 +45,7 @@ fun mangaListDetailedItemAD( .placeholder(R.drawable.ic_placeholder) .fallback(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder) + .scale(Scale.FILL) .allowRgb565(true) .lifecycle(lifecycleOwner) .enqueueWith(coil) @@ -57,7 +59,7 @@ fun mangaListDetailedItemAD( badge = null imageRequest?.dispose() imageRequest = null - CoilUtils.clear(binding.imageViewCover) + CoilUtils.dispose(binding.imageViewCover) binding.imageViewCover.setImageDrawable(null) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt index 18696de6b..5087baa3f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import coil.request.Disposable +import coil.size.Scale import coil.util.CoilUtils import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding @@ -44,6 +45,7 @@ fun mangaListItemAD( .placeholder(R.drawable.ic_placeholder) .fallback(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder) + .scale(Scale.FILL) .allowRgb565(true) .lifecycle(lifecycleOwner) .enqueueWith(coil) @@ -55,7 +57,7 @@ fun mangaListItemAD( badge = null imageRequest?.dispose() imageRequest = null - CoilUtils.clear(binding.imageViewCover) + CoilUtils.dispose(binding.imageViewCover) binding.imageViewCover.setImageDrawable(null) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt index 81c79e1ae..7583b2e8c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt @@ -6,7 +6,6 @@ import android.os.Bundle import android.view.* import androidx.appcompat.widget.SearchView import androidx.fragment.app.FragmentManager -import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseBottomSheet @@ -14,11 +13,14 @@ import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.utils.BottomSheetToolbarController -class FilterBottomSheet : BaseBottomSheet(), MenuItem.OnActionExpandListener, - SearchView.OnQueryTextListener, DialogInterface.OnKeyListener { +class FilterBottomSheet : + BaseBottomSheet(), + MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener, + DialogInterface.OnKeyListener { private val viewModel by sharedViewModel( - owner = { from(requireParentFragment(), requireParentFragment()) } + owner = { requireParentFragment() } ) override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt index acba2466c..0cbb4fad7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt @@ -1,18 +1,18 @@ package org.koitharu.kotatsu.list.ui.filter import androidx.annotation.WorkerThread +import java.util.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.* -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -import java.util.* +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug class FilterCoordinator( private val repository: RemoteMangaRepository, @@ -113,7 +113,7 @@ class FilterCoordinator( FilterItem.Sort(it, isSelected = it == state.sortOrder) } } - if(allTags.isLoading || allTags.isError || tags.isNotEmpty()) { + if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) { list.add(FilterItem.Header(R.string.genres, state.tags.size)) tags.mapTo(list) { FilterItem.Tag(it, isChecked = it in state.tags) @@ -153,9 +153,7 @@ class FilterCoordinator( runCatching { repository.getTags() }.onFailure { error -> - if (BuildConfig.DEBUG) { - error.printStackTrace() - } + error.printStackTraceDebug() }.getOrNull() } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt index 3366248a4..f8cb28657 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt @@ -11,7 +11,7 @@ import org.koitharu.kotatsu.local.ui.LocalListViewModel val localModule get() = module { - single { LocalStorageManager(androidContext(), get()) } + factory { LocalStorageManager(androidContext(), get()) } single { LocalMangaRepository(get()) } factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt index ff04a2eff..c773a05d3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt @@ -2,41 +2,52 @@ package org.koitharu.kotatsu.local.data import android.net.Uri import android.webkit.MimeTypeMap -import coil.bitmap.BitmapPool +import coil.ImageLoader import coil.decode.DataSource -import coil.decode.Options -import coil.fetch.FetchResult +import coil.decode.ImageSource import coil.fetch.Fetcher import coil.fetch.SourceResult -import coil.size.Size -import java.util.zip.ZipFile +import coil.request.Options import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okio.buffer import okio.source +import java.util.zip.ZipFile -class CbzFetcher : Fetcher { +class CbzFetcher( + private val uri: Uri, + private val options: Options +) : Fetcher { - override suspend fun fetch( - pool: BitmapPool, - data: Uri, - size: Size, - options: Options, - ): FetchResult = runInterruptible(Dispatchers.IO) { - val zip = ZipFile(data.schemeSpecificPart) - val entry = zip.getEntry(data.fragment) + 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 = ExtraCloseableBufferedSource( + zip.getInputStream(entry).source().buffer(), + zip, + ) SourceResult( - source = ExtraCloseableBufferedSource( - zip.getInputStream(entry).source().buffer(), - zip, + source = ImageSource( + source = bufferedSource, + context = options.context, + metadata = CbzMetadata(uri), ), mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext), - dataSource = DataSource.DISK + dataSource = DataSource.DISK, ) } - override fun key(data: Uri) = data.toString() + class Factory : Fetcher.Factory { - override fun handles(data: Uri) = data.scheme == "cbz" + 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() } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 4e5115ac8..fc2dbad03 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -15,11 +15,11 @@ import androidx.core.net.toUri import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.utils.ShareHelper +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.progress.Progress class LocalListFragment : MangaListFragment(), ActivityResultCallback> { @@ -68,9 +68,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback - if (BuildConfig.DEBUG) { - error.printStackTrace() - } + error.printStackTraceDebug() } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt index e8c69d824..c6e11107b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt @@ -11,7 +11,7 @@ import org.koitharu.kotatsu.main.ui.protect.ProtectViewModel val mainModule get() = module { single { AppProtectHelper(get()) } - single { ShortcutsRepository(androidContext(), get(), get(), get()) } + factory { ShortcutsRepository(androidContext(), get(), get(), get()) } viewModel { MainViewModel(get(), get()) } viewModel { ProtectViewModel(get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 24805aa84..49bde074a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -7,8 +7,10 @@ import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.result.ActivityResultCallback import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.view.ActionMode +import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat import androidx.core.graphics.Insets import androidx.core.view.* @@ -17,12 +19,13 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope +import androidx.transition.TransitionManager import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.LayoutParams.* import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.get import org.koin.androidx.viewmodel.ext.android.viewModel @@ -55,6 +58,7 @@ import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.tracker.ui.FeedFragment import org.koitharu.kotatsu.tracker.work.TrackWorker +import org.koitharu.kotatsu.utils.VoiceInputContract import org.koitharu.kotatsu.utils.ext.* import com.google.android.material.R as materialR @@ -75,6 +79,7 @@ class MainActivity : private lateinit var navHeaderBinding: NavigationHeaderBinding private var drawerToggle: ActionBarDrawerToggle? = null private var drawer: DrawerLayout? = null + private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback()) override val appBar: AppBarLayout get() = binding.appbar @@ -119,6 +124,7 @@ class MainActivity : } binding.fab.setOnClickListener(this@MainActivity) + binding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let { if (it is HistoryListFragment) binding.fab.show() else binding.fab.hide() @@ -135,6 +141,7 @@ class MainActivity : viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.remoteSources.observe(this, this::updateSideMenu) viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled) + viewModel.isTrackerEnabled.observe(this, this::setTrackerEnabled) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -277,6 +284,19 @@ class MainActivity : searchSuggestionViewModel.onQueryChanged(query) } + override fun onVoiceSearchClick() { + val options = binding.searchView.drawableEnd?.bounds?.let { bounds -> + ActivityOptionsCompat.makeScaleUpAnimation( + binding.searchView, + bounds.centerX(), + bounds.centerY(), + bounds.width(), + bounds.height(), + ) + } + voiceInputLauncher.tryLaunch(binding.searchView.hint?.toString(), options) + } + override fun onClearSearchHistory() { MaterialAlertDialogBuilder(this) .setTitle(R.string.clear_search_history) @@ -340,6 +360,14 @@ class MainActivity : item.isVisible = isEnabled } + private fun setTrackerEnabled(isEnabled: Boolean) { + val item = binding.navigationView.menu.findItem(R.id.nav_feed) ?: return + if (!isEnabled && item.isChecked) { + binding.navigationView.setCheckedItem(R.id.nav_history) + } + item.isVisible = isEnabled + } + private fun openDefaultSection() { when (viewModel.defaultSection) { AppSection.LOCAL -> { @@ -373,32 +401,44 @@ class MainActivity : } private fun onSearchOpened() { + TransitionManager.beginDelayedTransition(binding.appbar) drawerToggle?.isDrawerIndicatorEnabled = false + binding.toolbarCard.updateLayoutParams { + scrollFlags = SCROLL_FLAG_NO_SCROLL + } + binding.appbar.setBackgroundColor(getThemeColor(materialR.attr.colorSurfaceVariant)) + binding.appbar.updatePadding(left = 0, right = 0) adjustDrawerLock() adjustFabVisibility(isSearchOpened = true) } private fun onSearchClosed() { + TransitionManager.beginDelayedTransition(binding.appbar) drawerToggle?.isDrawerIndicatorEnabled = true + binding.toolbarCard.updateLayoutParams { + scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS + } + binding.appbar.background = null + val padding = resources.getDimensionPixelOffset(R.dimen.margin_normal) + binding.appbar.updatePadding(left = padding, right = padding) adjustDrawerLock() adjustFabVisibility(isSearchOpened = false) } private fun onFirstStart() { - lifecycleScope.launch(Dispatchers.Default) { - TrackWorker.setup(applicationContext) - SuggestionsWorker.setup(applicationContext) - if (AppUpdateChecker.isUpdateSupported(this@MainActivity)) { + lifecycleScope.launchWhenResumed { + val isUpdateSupported = withContext(Dispatchers.Default) { + TrackWorker.setup(applicationContext) + SuggestionsWorker.setup(applicationContext) + AppUpdateChecker.isUpdateSupported(this@MainActivity) + } + if (isUpdateSupported) { AppUpdateChecker(this@MainActivity).checkIfNeeded() } val settings = get() when { - !settings.isSourcesSelected -> withContext(Dispatchers.Main) { - OnboardDialogFragment.showWelcome(supportFragmentManager) - } - settings.newSources.isNotEmpty() -> withContext(Dispatchers.Main) { - NewSourcesDialogFragment.show(supportFragmentManager) - } + !settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager) + settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager) } } } @@ -427,4 +467,13 @@ class MainActivity : if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED ) } + + private inner class VoiceInputCallback : ActivityResultCallback { + + override fun onActivityResult(result: String?) { + if (result != null) { + binding.searchView.query = result + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt index f2b98d7e0..c3a681343 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException +import org.koitharu.kotatsu.core.prefs.AppSection import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsLiveData import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.SingleLiveEvent @@ -15,17 +17,27 @@ import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct class MainViewModel( private val historyRepository: HistoryRepository, - settings: AppSettings + private val settings: AppSettings, ) : BaseViewModel() { val onOpenReader = SingleLiveEvent() - var defaultSection by settings::defaultSection + var defaultSection: AppSection + get() = settings.defaultSection + set(value) { + settings.defaultSection = value + } - val isSuggestionsEnabled = settings.observe() - .filter { it == AppSettings.KEY_SUGGESTIONS } - .onStart { emit("") } - .map { settings.isSuggestionsEnabled } - .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + val isSuggestionsEnabled = settings.observeAsLiveData( + context = viewModelScope.coroutineContext + Dispatchers.Default, + key = AppSettings.KEY_SUGGESTIONS, + valueProducer = { isSuggestionsEnabled }, + ) + + val isTrackerEnabled = settings.observeAsLiveData( + context = viewModelScope.coroutineContext + Dispatchers.Default, + key = AppSettings.KEY_TRACKER_ENABLED, + valueProducer = { isTrackerEnabled }, + ) val isResumeEnabled = historyRepository .observeHasItems() diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt index f27f061c6..a27fb9e8d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt @@ -11,7 +11,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderViewModel val readerModule get() = module { - single { MangaDataRepository(get()) } + factory { MangaDataRepository(get()) } single { PagesCache(get()) } factory { PageSaveHelper(get(), androidContext()) } @@ -26,6 +26,7 @@ val readerModule shortcutsRepository = get(), settings = get(), pageSaveHelper = get(), + bookmarksRepository = get(), ) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/data/ModelMapping.kt b/app/src/main/java/org/koitharu/kotatsu/reader/data/ModelMapping.kt new file mode 100644 index 000000000..289f44386 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/data/ModelMapping.kt @@ -0,0 +1,27 @@ +package org.koitharu.kotatsu.reader.data + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter + +fun Manga.filterChapters(branch: String?): Manga { + if (chapters.isNullOrEmpty()) return this + return copy(chapters = chapters?.filter { it.branch == branch }) +} + +private fun Manga.copy(chapters: List?) = 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, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index a2d8f7ac5..3e19c7036 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -2,19 +2,26 @@ package org.koitharu.kotatsu.reader.ui import android.content.Context import android.net.Uri +import android.webkit.MimeTypeMap import androidx.activity.result.ActivityResultLauncher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrl import okio.IOException +import org.koitharu.kotatsu.base.domain.MangaUtils import org.koitharu.kotatsu.local.data.PagesCache 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 kotlin.coroutines.Continuation -import kotlin.coroutines.coroutineContext import kotlin.coroutines.resume +private const val MAX_FILENAME_LENGTH = 10 +private const val EXTENSION_FALLBACK = "png" + class PageSaveHelper( private val cache: PagesCache, context: Context, @@ -28,22 +35,17 @@ class PageSaveHelper( page: MangaPage, saveLauncher: ActivityResultLauncher, ): Uri { - var pageFile = cache[page.url] - var fileName = pageFile?.name - if (fileName == null) { - fileName = pageLoader.getPageUrl(page).toHttpUrl().pathSegments.last() - } - val cc = coroutineContext - val destination = suspendCancellableCoroutine { cont -> - continuation = cont - Dispatchers.Main.dispatch(cc) { - saveLauncher.launch(fileName) + val pageUrl = pageLoader.getPageUrl(page) + val pageFile = pageLoader.loadPage(page, force = false) + val proposedName = getProposedFileName(pageUrl, pageFile) + val destination = withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + continuation = cont + saveLauncher.launch(proposedName) + }.also { + continuation = null } } - continuation = null - if (pageFile == null) { - pageFile = pageLoader.loadPage(page, force = false) - } runInterruptible(Dispatchers.IO) { contentResolver.openOutputStream(destination)?.use { output -> pageFile.inputStream().use { input -> @@ -57,4 +59,19 @@ class PageSaveHelper( fun onActivityResult(uri: Uri): Boolean = continuation?.apply { resume(uri) } != null + + private suspend fun getProposedFileName(url: String, file: File): String { + var name = url.toHttpUrl().pathSegments.last() + var extension = name.substringAfterLast('.', "") + name = name.substringBeforeLast('.') + if (extension.length !in 2..4) { + val mimeType = MangaUtils.getImageMimeType(file) + extension = if (mimeType != null) { + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK + } else { + EXTENSION_FALLBACK + } + } + return name.toFileNameSafe().take(MAX_FILENAME_LENGTH) + "." + extension + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index ca9228207..e4a29b948 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -6,11 +6,13 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.* -import android.widget.Toast import androidx.activity.result.ActivityResultCallback import androidx.core.graphics.Insets -import androidx.core.view.* -import androidx.fragment.app.commit +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.transition.Slide import androidx.transition.TransitionManager @@ -29,6 +31,7 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.ui.BaseFullscreenActivity +import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.prefs.ReaderMode @@ -36,11 +39,7 @@ import org.koitharu.kotatsu.databinding.ActivityReaderBinding 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.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState -import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment -import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment -import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.settings.SettingsActivity @@ -50,6 +49,8 @@ import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.hasGlobalPoint import org.koitharu.kotatsu.utils.ext.observeWithPrevious +import org.koitharu.kotatsu.utils.ext.postDelayed +import java.util.concurrent.TimeUnit class ReaderActivity : BaseFullscreenActivity(), @@ -74,13 +75,13 @@ class ReaderActivity : private lateinit var controlDelegate: ReaderControlDelegate private val savePageRequest = registerForActivityResult(PageSaveContract(), this) private var gestureInsets: Insets = Insets.NONE - - private val reader - get() = supportFragmentManager.findFragmentById(R.id.container) as? BaseReader<*> + private lateinit var readerManager: ReaderManager + private val hideUiRunnable = Runnable { setUiIsVisible(false) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityReaderBinding.inflate(layoutInflater)) + readerManager = ReaderManager(supportFragmentManager, R.id.container) supportActionBar?.setDisplayHomeAsUpEnabled(true) touchHelper = GridTouchHelper(this, this) orientationHelper = ScreenOrientationHelper(this) @@ -90,6 +91,7 @@ class ReaderActivity : insetsDelegate.interceptingWindowInsetsListener = this orientationHelper.observeAutoOrientation() + .flowWithLifecycle(lifecycle) .onEach { binding.toolbarBottom.menu.findItem(R.id.action_screen_rotate).isVisible = !it }.launchIn(lifecycleScope) @@ -103,36 +105,29 @@ class ReaderActivity : onLoadingStateChanged(viewModel.isLoading.value == true) } viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure) + viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged) + viewModel.onShowToast.observe(this) { msgId -> + Snackbar.make(binding.container, msgId, Snackbar.LENGTH_SHORT) + .setAnchorView(binding.appbarBottom) + .show() + } } private fun onInitReader(mode: ReaderMode) { - val currentReader = reader - when (mode) { - ReaderMode.WEBTOON -> if (currentReader !is WebtoonReaderFragment) { - supportFragmentManager.commit { - replace(R.id.container, WebtoonReaderFragment()) - } - } - ReaderMode.REVERSED -> if (currentReader !is ReversedReaderFragment) { - supportFragmentManager.commit { - replace(R.id.container, ReversedReaderFragment()) - } - } - ReaderMode.STANDARD -> if (currentReader !is PagerReaderFragment) { - supportFragmentManager.commit { - replace(R.id.container, PagerReaderFragment()) - } - } + if (readerManager.currentMode != mode) { + readerManager.replace(mode) } - binding.toolbarBottom.menu.findItem(R.id.action_reader_mode).setIcon( - when (mode) { - ReaderMode.WEBTOON -> R.drawable.ic_script - ReaderMode.REVERSED -> R.drawable.ic_read_reversed - ReaderMode.STANDARD -> R.drawable.ic_book_page - } - ) - binding.appbarTop.postDelayed(1000) { - setUiIsVisible(false) + val iconRes = when (mode) { + ReaderMode.WEBTOON -> R.drawable.ic_script + ReaderMode.REVERSED -> R.drawable.ic_read_reversed + ReaderMode.STANDARD -> R.drawable.ic_book_page + } + binding.toolbarBottom.menu.findItem(R.id.action_reader_mode).run { + setIcon(iconRes) + setVisible(true) + } + if (binding.appbarTop.isVisible) { + lifecycle.postDelayed(hideUiRunnable, TimeUnit.SECONDS.toMillis(1)) } } @@ -144,18 +139,8 @@ class ReaderActivity : override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_reader_mode -> { - ReaderConfigDialog.show( - supportFragmentManager, - when (reader) { - is PagerReaderFragment -> ReaderMode.STANDARD - is WebtoonReaderFragment -> ReaderMode.WEBTOON - is ReversedReaderFragment -> ReaderMode.REVERSED - else -> { - showWaitWhileLoading() - return false - } - } - ) + val currentMode = readerManager.currentMode ?: return false + ReaderConfigDialog.show(supportFragmentManager, currentMode) } R.id.action_settings -> { startActivity(SettingsActivity.newReaderSettingsIntent(this)) @@ -177,17 +162,24 @@ class ReaderActivity : supportFragmentManager, pages, title?.toString().orEmpty(), - reader?.getCurrentState()?.page ?: -1 + readerManager.currentReader?.getCurrentState()?.page ?: -1, ) } else { - showWaitWhileLoading() + return false } } R.id.action_save_page -> { viewModel.getCurrentPage()?.also { page -> - viewModel.saveCurrentState(reader?.getCurrentState()) + viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) viewModel.saveCurrentPage(page, savePageRequest) - } ?: showWaitWhileLoading() + } ?: return false + } + R.id.action_bookmark -> { + if (viewModel.isBookmarkAdded.value == true) { + viewModel.removeBookmark() + } else { + viewModel.addBookmark() + } } else -> return super.onOptionsItemSelected(item) } @@ -202,10 +194,14 @@ class ReaderActivity : val hasPages = !viewModel.content.value?.pages.isNullOrEmpty() binding.layoutLoading.isVisible = isLoading && !hasPages if (isLoading && hasPages) { - binding.toastView.show(R.string.loading_, true) + binding.toastView.show(R.string.loading_) } else { binding.toastView.hide() } + val menu = binding.toolbarBottom.menu + menu.findItem(R.id.action_bookmark).isVisible = hasPages + menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages + menu.findItem(R.id.action_save_page).isVisible = hasPages } private fun onError(e: Throwable) { @@ -265,14 +261,14 @@ class ReaderActivity : val index = pages.indexOfFirst { it.id == page.id } if (index != -1) { withContext(Dispatchers.Main) { - reader?.switchPageTo(index, true) + readerManager.currentReader?.switchPageTo(index, true) } } } } override fun onReaderModeChanged(mode: ReaderMode) { - viewModel.saveCurrentState(reader?.getCurrentState()) + viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) viewModel.switchMode(mode) } @@ -290,12 +286,6 @@ class ReaderActivity : } } - private fun showWaitWhileLoading() { - Toast.makeText(this, R.string.wait_for_loading_finish, Toast.LENGTH_SHORT).apply { - setGravity(Gravity.CENTER, 0, 0) - }.show() - } - private fun setWindowSecure(isSecure: Boolean) { if (isSecure) { window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) @@ -309,8 +299,8 @@ class ReaderActivity : val transition = TransitionSet() .setOrdering(TransitionSet.ORDERING_TOGETHER) .addTransition(Slide(Gravity.TOP).addTarget(binding.appbarTop)) - binding.appbarBottom?.let { botomBar -> - transition.addTransition(Slide(Gravity.BOTTOM).addTarget(botomBar)) + binding.appbarBottom?.let { bottomBar -> + transition.addTransition(Slide(Gravity.BOTTOM).addTarget(bottomBar)) } TransitionManager.beginDelayedTransition(binding.root, transition) binding.appbarTop.isVisible = isUiVisible @@ -344,13 +334,19 @@ class ReaderActivity : override fun onWindowInsetsChanged(insets: Insets) = Unit override fun switchPageBy(delta: Int) { - reader?.switchPageBy(delta) + readerManager.currentReader?.switchPageBy(delta) } override fun toggleUiVisibility() { setUiIsVisible(!binding.appbarTop.isVisible) } + private fun onBookmarkStateChanged(isAdded: Boolean) { + val menuItem = binding.toolbarBottom.menu.findItem(R.id.action_bookmark) ?: return + menuItem.setTitle(if (isAdded) R.string.bookmark_remove else R.string.bookmark_add) + menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark) + } + private fun onUiStateChanged(uiState: ReaderUiState, previous: ReaderUiState?) { title = uiState.chapterName ?: uiState.mangaName ?: getString(R.string.loading_) supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) { @@ -419,6 +415,11 @@ class ReaderActivity : .putExtra(EXTRA_STATE, state) } + fun newIntent(context: Context, bookmark: Bookmark): Intent { + val state = ReaderState(bookmark.chapterId, bookmark.page, bookmark.scroll) + return newIntent(context, bookmark.manga, state) + } + fun newIntent(context: Context, mangaId: Long): Intent { return Intent(context, ReaderActivity::class.java) .putExtra(MangaIntent.KEY_ID, mangaId) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt index f8c5d73c0..dbe853894 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt @@ -5,14 +5,16 @@ import android.view.SoundEffectConstants import android.view.View import androidx.lifecycle.LifecycleCoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.utils.GridTouchHelper -@Suppress("UNUSED_PARAMETER") class ReaderControlDelegate( - private val scope: LifecycleCoroutineScope, - private val settings: AppSettings, + scope: LifecycleCoroutineScope, + settings: AppSettings, private val listener: OnInteractionListener ) { @@ -20,12 +22,8 @@ class ReaderControlDelegate( private var isVolumeKeysSwitchEnabled: Boolean = false init { - settings.observe() - .filter { it == AppSettings.KEY_READER_SWITCHERS } - .map { settings.readerPageSwitch } - .onStart { emit(settings.readerPageSwitch) } - .distinctUntilChanged() - .flowOn(Dispatchers.IO) + settings.observeAsFlow(AppSettings.KEY_READER_SWITCHERS) { readerPageSwitch } + .flowOn(Dispatchers.Default) .onEach { isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in it isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in it @@ -57,7 +55,7 @@ class ReaderControlDelegate( } } - fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = when (keyCode) { + fun onKeyDown(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent?): Boolean = when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> if (isVolumeKeysSwitchEnabled) { listener.switchPageBy(-1) true @@ -92,9 +90,11 @@ class ReaderControlDelegate( else -> false } - fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { - return (isVolumeKeysSwitchEnabled && - (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP)) + fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent?): Boolean { + return ( + isVolumeKeysSwitchEnabled && + (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) + ) } interface OnInteractionListener { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderManager.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderManager.kt new file mode 100644 index 000000000..c5497fe8a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderManager.kt @@ -0,0 +1,45 @@ +package org.koitharu.kotatsu.reader.ui + +import androidx.annotation.IdRes +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import org.koitharu.kotatsu.core.prefs.ReaderMode +import org.koitharu.kotatsu.reader.ui.pager.BaseReader +import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment +import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment +import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment +import java.util.* + +class ReaderManager( + private val fragmentManager: FragmentManager, + @IdRes private val containerResId: Int, +) { + + private val modeMap = EnumMap>>(ReaderMode::class.java) + + init { + modeMap[ReaderMode.STANDARD] = PagerReaderFragment::class.java + modeMap[ReaderMode.REVERSED] = ReversedReaderFragment::class.java + modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java + } + + val currentReader: BaseReader<*>? + get() = fragmentManager.findFragmentById(containerResId) as? BaseReader<*> + + val currentMode: ReaderMode? + get() { + val readerClass = currentReader?.javaClass ?: return null + return modeMap.entries.find { it.value == readerClass }?.key + } + + fun replace(newMode: ReaderMode) { + val readerClass = requireNotNull(modeMap[newMode]) + fragmentManager.commit { + replace(containerResId, readerClass, null, null) + } + } + + fun replace(reader: BaseReader<*>) { + fragmentManager.commit { replace(containerResId, reader) } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt index 0fe72b499..2eb7cc960 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt @@ -9,23 +9,20 @@ import org.koitharu.kotatsu.parsers.model.Manga data class ReaderState( val chapterId: Long, val page: Int, - val scroll: Int + val scroll: Int, ) : Parcelable { - companion object { + constructor(history: MangaHistory) : this( + chapterId = history.chapterId, + page = history.page, + scroll = history.scroll, + ) - fun from(history: MangaHistory) = ReaderState( - chapterId = history.chapterId, - page = history.page, - scroll = history.scroll - ) - - fun initial(manga: Manga, branch: String?) = ReaderState( - chapterId = manga.chapters?.firstOrNull { - it.branch == branch - }?.id ?: error("Cannot find first chapter"), - page = 0, - scroll = 0 - ) - } + constructor(manga: Manga, branch: String?) : this( + chapterId = manga.chapters?.firstOrNull { + it.branch == branch + }?.id ?: error("Cannot find first chapter"), + page = 0, + scroll = 0, + ) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt index f9852f4c6..a2acc8df7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt @@ -1,13 +1,11 @@ package org.koitharu.kotatsu.reader.ui import android.content.Context -import android.graphics.Color import android.util.AttributeSet import android.view.Gravity import android.view.ViewGroup import androidx.annotation.StringRes import androidx.core.view.isVisible -import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.transition.Fade import androidx.transition.Slide import androidx.transition.TransitionManager @@ -15,26 +13,28 @@ import androidx.transition.TransitionSet import com.google.android.material.textview.MaterialTextView class ReaderToastView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, ) : MaterialTextView(context, attrs, defStyleAttr) { private var hideRunnable = Runnable { hide() } - fun show(message: CharSequence, isLoading: Boolean) { + fun show(message: CharSequence) { removeCallbacks(hideRunnable) text = message setupTransition() isVisible = true } - fun show(@StringRes messageId: Int, isLoading: Boolean) { - show(context.getString(messageId), isLoading) + fun show(@StringRes messageId: Int) { + show(context.getString(messageId)) } fun showTemporary(message: CharSequence, duration: Long) { - show(message, false) + show(message) postDelayed(hideRunnable, duration) } @@ -49,7 +49,7 @@ class ReaderToastView @JvmOverloads constructor( super.onDetachedFromWindow() } - private fun setupTransition () { + private fun setupTransition() { val parentView = parent as? ViewGroup ?: return val transition = TransitionSet() .setOrdering(TransitionSet.ORDERING_TOGETHER) @@ -58,14 +58,4 @@ class ReaderToastView @JvmOverloads constructor( .addTransition(Fade()) TransitionManager.beginDelayedTransition(parentView, transition) } - - // FIXME use it as compound drawable - private fun createProgressDrawable(): CircularProgressDrawable { - val drawable = CircularProgressDrawable(context) - drawable.setStyle(CircularProgressDrawable.DEFAULT) - drawable.arrowEnabled = false - drawable.setColorSchemeColors(Color.WHITE) - drawable.centerRadius = lineHeight / 3f - return drawable - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index bfe5ac663..931461de0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -3,35 +3,40 @@ package org.koitharu.kotatsu.reader.ui import android.net.Uri import android.util.LongSparseArray import androidx.activity.result.ActivityResultLauncher +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import java.util.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import org.koin.core.component.KoinComponent -import org.koin.core.component.get -import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaUtils import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException import org.koitharu.kotatsu.core.os.ShortcutsRepository 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.prefs.ScreenshotsPolicy +import org.koitharu.kotatsu.core.prefs.* import org.koitharu.kotatsu.history.domain.HistoryRepository 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.reader.data.filterChapters import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.utils.SingleLiveEvent -import org.koitharu.kotatsu.utils.ext.IgnoreErrors import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.processLifecycleScope +private const val BOUNDS_PAGE_OFFSET = 2 +private const val PAGES_TRIM_THRESHOLD = 120 +private const val PREFETCH_LIMIT = 10 + class ReaderViewModel( private val intent: MangaIntent, initialState: ReaderState?, @@ -39,12 +44,14 @@ class ReaderViewModel( private val dataRepository: MangaDataRepository, private val historyRepository: HistoryRepository, private val shortcutsRepository: ShortcutsRepository, + private val bookmarksRepository: BookmarksRepository, private val settings: AppSettings, private val pageSaveHelper: PageSaveHelper, ) : BaseViewModel() { private var loadingJob: Job? = null private var pageSaveJob: Job? = null + private var bookmarkJob: Job? = null private val currentState = MutableStateFlow(initialState) private val mangaData = MutableStateFlow(intent.manga) private val chapters = LongSparseArray() @@ -53,6 +60,7 @@ class ReaderViewModel( val readerMode = MutableLiveData() val onPageSaved = SingleLiveEvent() + val onShowToast = SingleLiveEvent() val uiState = combine( mangaData, currentState, @@ -70,25 +78,32 @@ class ReaderViewModel( val manga: Manga? get() = mangaData.value - val readerAnimation = settings.observe() - .filter { it == AppSettings.KEY_READER_ANIMATION } - .map { settings.readerAnimation } - .onStart { emit(settings.readerAnimation) } - .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.IO) + val readerAnimation = settings.observeAsLiveData( + context = viewModelScope.coroutineContext + Dispatchers.Default, + key = AppSettings.KEY_READER_ANIMATION, + valueProducer = { readerAnimation } + ) val isScreenshotsBlockEnabled = combine( mangaData, - settings.observe() - .filter { it == AppSettings.KEY_SCREENSHOTS_POLICY } - .onStart { emit("") } - .map { settings.screenshotsPolicy }, + settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy }, ) { manga, policy -> policy == ScreenshotsPolicy.BLOCK_ALL || (policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw) - }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.IO) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) val onZoomChanged = SingleLiveEvent() + val isBookmarkAdded: LiveData = currentState.flatMapLatest { state -> + val manga = mangaData.value + if (state == null || manga == null) { + flowOf(false) + } else { + bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) + .map { it != null } + } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + init { loadImpl() subscribeToSettings() @@ -124,7 +139,7 @@ class ReaderViewModel( if (state != null) { currentState.value = state } - saveState( + historyRepository.saveStateAsync( mangaData.value ?: return, state ?: currentState.value ?: return ) @@ -151,9 +166,7 @@ class ReaderViewModel( } catch (e: CancellationException) { throw e } catch (e: Exception) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } + e.printStackTraceDebug() onPageSaved.postCall(null) } } @@ -187,10 +200,9 @@ class ReaderViewModel( fun onCurrentPageChanged(position: Int) { val pages = content.value?.pages ?: return - pages.getOrNull(position)?.let { - val currentValue = currentState.value - if (currentValue != null && currentValue.chapterId != it.chapterId) { - currentState.value = currentValue.copy(chapterId = it.chapterId) + pages.getOrNull(position)?.let { page -> + currentState.update { cs -> + cs?.copy(chapterId = page.chapterId, page = page.index) } } if (pages.isEmpty() || loadingJob?.isActive == true) { @@ -207,6 +219,41 @@ class ReaderViewModel( } } + fun addBookmark() { + if (bookmarkJob?.isActive == true) { + return + } + bookmarkJob = launchJob { + loadingJob?.join() + val state = checkNotNull(currentState.value) + val page = checkNotNull(getCurrentPage()) { "Page not found" } + val bookmark = Bookmark( + manga = checkNotNull(mangaData.value), + pageId = page.id, + chapterId = state.chapterId, + page = state.page, + scroll = state.scroll, + imageUrl = page.preview ?: pageLoader.getPageUrl(page), + createdAt = Date(), + ) + bookmarksRepository.addBookmark(bookmark) + onShowToast.call(R.string.bookmark_added) + } + } + + fun removeBookmark() { + if (bookmarkJob?.isActive == true) { + return + } + bookmarkJob = launchJob { + loadingJob?.join() + val manga = checkNotNull(mangaData.value) + val page = checkNotNull(getCurrentPage()) { "Page not found" } + bookmarksRepository.removeBookmark(manga.id, page.id) + onShowToast.call(R.string.bookmark_removed) + } + } + private fun loadImpl() { loadingJob = launchLoadingJob(Dispatchers.Default) { var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") @@ -217,24 +264,16 @@ class ReaderViewModel( chapters.put(it.id, it) } // determine mode - val mode = dataRepository.getReaderMode(manga.id) ?: manga.chapters?.randomOrNull()?.let { - val pages = repo.getPages(it) - val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages) - val newMode = getReaderMode(isWebtoon) - if (isWebtoon != null) { - dataRepository.savePreferences(manga, newMode) - } - newMode - } ?: error("There are no chapters in this manga") + val mode = detectReaderMode(manga, repo) // obtain state if (currentState.value == null) { currentState.value = historyRepository.getOne(manga)?.let { - ReaderState.from(it) - } ?: ReaderState.initial(manga, preselectedBranch) + ReaderState(it) + } ?: ReaderState(manga, preselectedBranch) } val branch = chapters[currentState.value?.chapterId ?: 0L].branch - mangaData.value = manga.copy(chapters = manga.chapters?.filter { it.branch == branch }) + mangaData.value = manga.filterChapters(branch) readerMode.postValue(mode) val pages = loadChapter(requireNotNull(currentState.value).chapterId) @@ -248,18 +287,12 @@ class ReaderViewModel( } } - private fun getReaderMode(isWebtoon: Boolean?) = when { - isWebtoon == true -> ReaderMode.WEBTOON - settings.isPreferRtlReader -> ReaderMode.REVERSED - else -> ReaderMode.STANDARD - } - private suspend fun loadChapter(chapterId: Long): List { val manga = checkNotNull(mangaData.value) { "Manga is null" } val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } val repo = MangaRepository(manga.source) return repo.getPages(chapter).mapIndexed { index, page -> - ReaderPage.from(page, index, chapterId) + ReaderPage(page, index, chapterId) } } @@ -297,9 +330,9 @@ class ReaderViewModel( private fun subscribeToSettings() { settings.observe() - .filter { it == AppSettings.KEY_ZOOM_MODE } - .onEach { onZoomChanged.postCall(Unit) } - .launchIn(viewModelScope + Dispatchers.IO) + .onEach { key -> + if (key == AppSettings.KEY_ZOOM_MODE) onZoomChanged.postCall(Unit) + }.launchIn(viewModelScope + Dispatchers.Default) } private fun List.trySublist(fromIndex: Int, toIndex: Int): List { @@ -312,39 +345,42 @@ class ReaderViewModel( } } - private fun Manga.copy(chapters: List?) = 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 suspend fun detectReaderMode(manga: Manga, repo: MangaRepository): ReaderMode { + dataRepository.getReaderMode(manga.id)?.let { return it } + val defaultMode = settings.defaultReaderMode + if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) { + return defaultMode + } + val chapter = currentState.value?.chapterId?.let(chapters::get) + ?: manga.chapters?.randomOrNull() + ?: error("There are no chapters in this manga") + val pages = repo.getPages(chapter) + return runCatching { + val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages) + if (isWebtoon) ReaderMode.WEBTOON else defaultMode + }.onSuccess { + dataRepository.savePreferences(manga, it) + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(defaultMode) + } +} - private companion object : KoinComponent { - - const val BOUNDS_PAGE_OFFSET = 2 - const val PAGES_TRIM_THRESHOLD = 120 - const val PREFETCH_LIMIT = 10 - - fun saveState(manga: Manga, state: ReaderState) { - processLifecycleScope.launch(Dispatchers.Default + IgnoreErrors) { - get().addOrUpdate( - manga = manga, - chapterId = state.chapterId, - page = state.page, - scroll = state.scroll - ) - } +/** + * This function is not a member of the ReaderViewModel + * because it should work independently of the ViewModel's lifecycle. + */ +private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState): Job { + return processLifecycleScope.launch(Dispatchers.Default) { + runCatching { + addOrUpdate( + manga = manga, + chapterId = state.chapterId, + page = state.page, + scroll = state.scroll + ) + }.onFailure { + it.printStackTraceDebug() } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt index bc3f3220e..6773f5dc3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt @@ -13,27 +13,24 @@ data class ReaderPage( val preview: String?, val chapterId: Long, val index: Int, - val source: MangaSource + val source: MangaSource, ) : Parcelable { + constructor(page: MangaPage, index: Int, chapterId: Long) : this( + id = page.id, + url = page.url, + referer = page.referer, + preview = page.preview, + chapterId = chapterId, + index = index, + source = page.source, + ) + fun toMangaPage() = MangaPage( id = id, url = url, referer = referer, preview = preview, - source = source + source = source, ) - - companion object { - - fun from(page: MangaPage, index: Int, chapterId: Long) = ReaderPage( - id = page.id, - url = page.url, - referer = page.referer, - preview = page.preview, - chapterId = chapterId, - index = index, - source = page.source - ) - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt index 201a4f4ae..8cddae963 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt @@ -3,7 +3,7 @@ package org.koitharu.kotatsu.reader.ui.thumbnails.adapter import android.graphics.drawable.Drawable import coil.ImageLoader import coil.request.ImageRequest -import coil.size.PixelSize +import coil.size.Size import com.google.android.material.R as materialR import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import kotlinx.coroutines.* @@ -27,7 +27,7 @@ fun pageThumbnailAD( var job: Job? = null val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) - val thumbSize = PixelSize( + val thumbSize = Size( width = gridWidth, height = (gridWidth * 13f / 18f).toInt() ) diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index b2a540baa..b57f0b30b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.* -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.ui.widgets.ChipsView @@ -21,6 +20,7 @@ import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug private const val FILTER_MIN_INTERVAL = 750L @@ -133,12 +133,10 @@ class RemoteListViewModel( } hasNextPage.value = list.isNotEmpty() } catch (e: Throwable) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } + e.printStackTraceDebug() listError.value = e if (!mangaList.value.isNullOrEmpty()) { - onError.postCall(e) + errorEvent.postCall(e) } } } @@ -158,4 +156,4 @@ class RemoteListViewModel( textSecondary = 0, actionStringRes = if (filterState.tags.isEmpty()) 0 else R.string.reset_filter, ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt index 1f14c6ee3..1d1fb43fc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt @@ -13,8 +13,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel val searchModule get() = module { - single { MangaSearchRepository(get(), get(), androidContext(), get()) } - + factory { MangaSearchRepository(get(), get(), androidContext(), get()) } factory { MangaSuggestionsProvider.createSuggestions(androidContext()) } viewModel { params -> diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt index b516a026a..3511f3b3d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt @@ -67,19 +67,19 @@ class GlobalSearchViewModel( searchJob = repository.globalSearch(query) .catch { e -> listError.value = e - isLoading.postValue(false) + loadingCounter.reset() }.onStart { mangaList.value = null listError.value = null - isLoading.postValue(true) + loadingCounter.increment() hasNextPage.value = true }.onEmpty { mangaList.value = emptyList() }.onCompletion { - isLoading.postValue(false) + loadingCounter.reset() hasNextPage.value = false }.onFirst { - isLoading.postValue(false) + loadingCounter.reset() }.onEach { mangaList.value = mangaList.value?.plus(it) ?: listOf(it) }.launchIn(viewModelScope + Dispatchers.Default) diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt index 9a1ffba8f..7d9a3b6cb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -9,6 +9,7 @@ import androidx.core.view.updatePadding import androidx.recyclerview.widget.ItemTouchHelper import org.koin.android.ext.android.get import org.koin.androidx.viewmodel.ext.android.sharedViewModel +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding import org.koitharu.kotatsu.main.ui.AppBarOwner @@ -43,11 +44,10 @@ class SearchSuggestionFragment : override fun onWindowInsetsChanged(insets: Insets) { val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top + val extraPadding = resources.getDimensionPixelOffset(R.dimen.list_spacing) binding.root.updatePadding( - top = headerHeight, - // left = insets.left, - // right = insets.right, - bottom = insets.bottom, + top = headerHeight + extraPadding, + bottom = insets.bottom + extraPadding, ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt index 9a942009b..ea9dfd6f2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt @@ -14,4 +14,6 @@ interface SearchSuggestionListener { fun onClearSearchHistory() fun onTagClick(tag: MangaTag) + + fun onVoiceSearchClick() } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt index 07e24ca16..261648fce 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt @@ -2,15 +2,20 @@ package org.koitharu.kotatsu.search.ui.widget import android.annotation.SuppressLint import android.content.Context +import android.os.Parcelable import android.util.AttributeSet import android.view.KeyEvent import android.view.MotionEvent +import android.view.SoundEffectConstants +import android.view.accessibility.AccessibilityEvent import android.view.inputmethod.EditorInfo import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatEditText import androidx.core.content.ContextCompat -import com.google.android.material.R +import com.google.android.material.R as materialR +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener +import org.koitharu.kotatsu.utils.ext.drawableEnd import org.koitharu.kotatsu.utils.ext.drawableStart private const val DRAWABLE_END = 2 @@ -18,11 +23,19 @@ private const val DRAWABLE_END = 2 class SearchEditText @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = R.attr.editTextStyle, + @AttrRes defStyleAttr: Int = materialR.attr.editTextStyle, ) : AppCompatEditText(context, attrs, defStyleAttr) { var searchSuggestionListener: SearchSuggestionListener? = null - private val clearIcon = ContextCompat.getDrawable(context, R.drawable.abc_ic_clear_material) + private val clearIcon = ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material) + private val voiceIcon = ContextCompat.getDrawable(context, R.drawable.ic_voice_input) + private var isEmpty = text.isNullOrEmpty() + + var isVoiceSearchEnabled: Boolean = false + set(value) { + field = value + updateActionIcon() + } var query: String get() = text?.trim()?.toString().orEmpty() @@ -57,15 +70,19 @@ class SearchEditText @JvmOverloads constructor( lengthAfter: Int, ) { super.onTextChanged(text, start, lengthBefore, lengthAfter) - setCompoundDrawablesRelativeWithIntrinsicBounds( - drawableStart, - null, - if (text.isNullOrEmpty()) null else clearIcon, - null, - ) + val empty = text.isNullOrEmpty() + if (isEmpty != empty) { + isEmpty = empty + updateActionIcon() + } searchSuggestionListener?.onQueryChanged(query) } + override fun onRestoreInstanceState(state: Parcelable?) { + super.onRestoreInstanceState(state) + updateActionIcon() + } + @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { @@ -76,7 +93,9 @@ class SearchEditText @JvmOverloads constructor( event.x.toInt() in (width - drawable.bounds.width() - paddingRight)..(width - paddingRight) } if (isOnDrawable) { - text?.clear() + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED) + playSoundEffect(SoundEffectConstants.CLICK) + onActionIconClick() return true } } @@ -87,4 +106,22 @@ class SearchEditText @JvmOverloads constructor( super.clearFocus() text?.clear() } + + private fun onActionIconClick() { + when { + !text.isNullOrEmpty() -> text?.clear() + isVoiceSearchEnabled -> searchSuggestionListener?.onVoiceSearchClick() + } + } + + private fun updateActionIcon() { + val icon = when { + !text.isNullOrEmpty() -> clearIcon + isVoiceSearchEnabled -> voiceIcon + else -> null + } + if (icon !== drawableEnd) { + setCompoundDrawablesRelativeWithIntrinsicBounds(drawableStart, null, icon, null) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt index 12d7f21ce..a9e2ab345 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt @@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.github.VersionId import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.parsers.util.byte2HexFormatted import org.koitharu.kotatsu.utils.FileSize +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import java.io.ByteArrayInputStream import java.io.InputStream import java.security.MessageDigest @@ -45,8 +46,8 @@ class AppUpdateChecker(private val activity: ComponentActivity) { suspend fun checkNow() = runCatching { val version = repo.getLatestVersion() - val newVersionId = VersionId.parse(version.name) - val currentVersionId = VersionId.parse(BuildConfig.VERSION_NAME) + val newVersionId = VersionId(version.name) + val currentVersionId = VersionId(BuildConfig.VERSION_NAME) val result = newVersionId > currentVersionId if (result) { withContext(Dispatchers.Main) { @@ -56,30 +57,27 @@ class AppUpdateChecker(private val activity: ComponentActivity) { settings.lastUpdateCheckTimestamp = System.currentTimeMillis() result }.onFailure { - it.printStackTrace() + it.printStackTraceDebug() }.getOrNull() @MainThread private fun showUpdateDialog(version: AppVersion) { + val message = buildString { + append(activity.getString(R.string.new_version_s, version.name)) + appendLine() + append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize))) + appendLine() + appendLine() + append(version.description) + } MaterialAlertDialogBuilder(activity) .setTitle(R.string.app_update_available) - .setMessage(buildString { - append(activity.getString(R.string.new_version_s, version.name)) - appendLine() - append( - activity.getString( - R.string.size_s, - FileSize.BYTES.format(activity, version.apkSize), - ) - ) - appendLine() - appendLine() - append(version.description) - }) + .setMessage(message) .setPositiveButton(R.string.download) { _, _ -> activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(version.apkUrl))) } .setNegativeButton(R.string.close, null) + .setCancelable(false) .create() .show() } @@ -102,7 +100,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) { PackageManager.GET_SIGNATURES ) } catch (e: PackageManager.NameNotFoundException) { - e.printStackTrace() + e.printStackTraceDebug() return null } val signatures = packageInfo?.signatures @@ -112,7 +110,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) { val cf = CertificateFactory.getInstance("X509") cf.generateCertificate(input) as X509Certificate } catch (e: CertificateException) { - e.printStackTrace() + e.printStackTraceDebug() return null } return try { @@ -120,12 +118,12 @@ class AppUpdateChecker(private val activity: ComponentActivity) { val publicKey: ByteArray = md.digest(c.encoded) publicKey.byte2HexFormatted() } catch (e: NoSuchAlgorithmException) { - e.printStackTrace() + e.printStackTraceDebug() null } catch (e: CertificateEncodingException) { - e.printStackTrace() + e.printStackTraceDebug() null } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt index 36031ec21..81817e8b5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt @@ -12,9 +12,9 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.utils.SliderPreference -import org.koitharu.kotatsu.utils.ext.names import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import java.util.* diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index 8bfd5e39a..800599441 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -3,18 +3,22 @@ package org.koitharu.kotatsu.settings import android.content.SharedPreferences import android.os.Bundle import android.view.View +import androidx.preference.ListPreference import androidx.preference.Preference -import java.io.File import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog +import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.utils.SliderPreference import org.koitharu.kotatsu.utils.ext.getStorageName +import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import java.io.File class ContentSettingsFragment : BasePreferenceFragment(R.string.content), @@ -36,6 +40,15 @@ class ContentSettingsFragment : true } } + findPreference(AppSettings.KEY_DOH)?.run { + entryValues = arrayOf( + DoHProvider.NONE, + DoHProvider.GOOGLE, + DoHProvider.CLOUDFLARE, + DoHProvider.ADGUARD, + ).names() + setDefaultValueCompat(DoHProvider.NONE.name) + } bindRemoteSourcesSummary() } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt index b8852f47e..ee15c796a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.settings import android.content.Context +import android.content.SharedPreferences import android.media.RingtoneManager import android.os.Bundle import android.view.View @@ -11,7 +12,9 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.settings.utils.RingtonePickContract -class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notifications) { +class NotificationSettingsLegacyFragment : + BasePreferenceFragment(R.string.notifications), + SharedPreferences.OnSharedPreferenceChangeListener { private val ringtonePickContract = registerForActivityResult( RingtonePickContract(get().getString(R.string.notification_sound)) @@ -25,15 +28,28 @@ class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notif override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_notifications) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) findPreference(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run { val uri = settings.notificationSound summary = RingtoneManager.getRingtone(context, uri)?.getTitle(context) ?: getString(R.string.silent) } + updateInfo() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_TRACKER_NOTIFICATIONS -> updateInfo() + } } override fun onPreferenceTreeClick(preference: Preference): Boolean { @@ -45,4 +61,9 @@ class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notif else -> super.onPreferenceTreeClick(preference) } } -} + + private fun updateInfo() { + findPreference(AppSettings.KEY_NOTIFICATIONS_INFO) + ?.isVisible = !settings.isTrackerNotificationsEnabled + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt index 5602d23c2..39f4de6c5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt @@ -1,26 +1,68 @@ package org.koitharu.kotatsu.settings +import android.content.SharedPreferences import android.os.Bundle +import android.view.View import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference +import androidx.preference.Preference import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ReaderMode +import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider -import org.koitharu.kotatsu.utils.ext.names import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat -class ReaderSettingsFragment : BasePreferenceFragment(R.string.reader_settings) { +class ReaderSettingsFragment : + BasePreferenceFragment(R.string.reader_settings), + SharedPreferences.OnSharedPreferenceChangeListener { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_reader) - findPreference(AppSettings.KEY_READER_SWITCHERS)?.let { - it.summaryProvider = MultiSummaryProvider(R.string.gestures_only) + findPreference(AppSettings.KEY_READER_MODE)?.run { + entryValues = arrayOf( + ReaderMode.STANDARD, + ReaderMode.REVERSED, + ReaderMode.WEBTOON, + ).names() + setDefaultValueCompat(ReaderMode.STANDARD.name) } - findPreference(AppSettings.KEY_ZOOM_MODE)?.let { - it.entryValues = ZoomMode.values().names() - it.setDefaultValueCompat(ZoomMode.FIT_CENTER.name) + findPreference(AppSettings.KEY_READER_SWITCHERS)?.run { + summaryProvider = MultiSummaryProvider(R.string.gestures_only) + } + findPreference(AppSettings.KEY_ZOOM_MODE)?.run { + entryValues = arrayOf( + ZoomMode.FIT_CENTER, + ZoomMode.FIT_HEIGHT, + ZoomMode.FIT_WIDTH, + ZoomMode.KEEP_START, + ).names() + setDefaultValueCompat(ZoomMode.FIT_CENTER.name) + } + updateReaderModeDependency() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_READER_MODE -> updateReaderModeDependency() + } + } + + private fun updateReaderModeDependency() { + findPreference(AppSettings.KEY_READER_MODE_DETECT)?.run { + isEnabled = settings.defaultReaderMode != ReaderMode.WEBTOON } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt index b1fd14c50..230ab0d32 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt @@ -17,8 +17,8 @@ import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel val settingsModule get() = module { - single { BackupRepository(get()) } - single { RestoreRepository(get()) } + factory { BackupRepository(get()) } + factory { RestoreRepository(get()) } single(createdAtStart = true) { AppSettings(androidContext()) } viewModel { BackupViewModel(get(), androidContext()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt index 384905df2..4ffa13c5b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt @@ -6,7 +6,6 @@ import androidx.preference.Preference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.parser.MangaRepository @@ -14,6 +13,7 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.serializableArgument import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.withArgs @@ -70,9 +70,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) { preference.title = getString(R.string.logged_in_as, username) }.onFailure { error -> preference.isEnabled = error is AuthRequiredException - if (BuildConfig.DEBUG) { - error.printStackTrace() - } + error.printStackTraceDebug() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt index 1e993f845..f1637def6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt @@ -1,21 +1,34 @@ package org.koitharu.kotatsu.settings import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings import android.text.style.URLSpan +import android.view.View import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.preference.MultiSelectListPreference import androidx.preference.Preference +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider -import org.koitharu.kotatsu.tracker.work.TrackWorker +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope -class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_chapters) { +class TrackerSettingsFragment : + BasePreferenceFragment(R.string.check_for_new_chapters), + SharedPreferences.OnSharedPreferenceChangeListener { + + private val repository by inject() + private val channels by inject() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_tracker) @@ -32,22 +45,81 @@ class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_ch } } } + updateCategoriesEnabled() + } + + override fun onResume() { + super.onResume() + updateCategoriesSummary() + updateNotificationsSummary() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String?) { + when (key) { + AppSettings.KEY_TRACKER_NOTIFICATIONS -> updateNotificationsSummary() + AppSettings.KEY_TRACK_SOURCES, + AppSettings.KEY_TRACKER_ENABLED -> updateCategoriesEnabled() + } } override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { - AppSettings.KEY_NOTIFICATIONS_SETTINGS -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + AppSettings.KEY_NOTIFICATIONS_SETTINGS -> when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) - .putExtra(Settings.EXTRA_CHANNEL_ID, TrackWorker.CHANNEL_ID) startActivity(intent) true - } else { + } + channels.areNotificationsDisabled -> { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", requireContext().packageName, null)) + startActivity(intent) + true + } + else -> { super.onPreferenceTreeClick(preference) } } + AppSettings.KEY_TRACK_CATEGORIES -> { + startActivity(CategoriesActivity.newIntent(preference.context)) + true + } else -> super.onPreferenceTreeClick(preference) } } + + private fun updateNotificationsSummary() { + val pref = findPreference(AppSettings.KEY_NOTIFICATIONS_SETTINGS) ?: return + pref.setSummary( + when { + channels.areNotificationsDisabled -> R.string.disabled + channels.isNotificationGroupEnabled() -> R.string.show_notification_new_chapters_on + else -> R.string.show_notification_new_chapters_off + } + ) + } + + private fun updateCategoriesEnabled() { + val pref = findPreference(AppSettings.KEY_TRACK_CATEGORIES) ?: return + pref.isEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources + } + + private fun updateCategoriesSummary() { + val pref = findPreference(AppSettings.KEY_TRACK_CATEGORIES) ?: return + viewLifecycleScope.launch { + val count = repository.getCategoriesCount() + pref.summary = getString(R.string.enabled_d_of_d, count[0], count[1]) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt index 278995dab..baa2a5217 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt @@ -51,7 +51,7 @@ class AppBackupAgent : BackupAgent() { } private fun createBackupFile() = runBlocking { - val repository = BackupRepository(MangaDatabase.create(applicationContext)) + val repository = BackupRepository(MangaDatabase(applicationContext)) BackupZipOutput(this@AppBackupAgent).use { backup -> backup.put(repository.createIndex()) backup.put(repository.dumpHistory()) @@ -63,7 +63,7 @@ class AppBackupAgent : BackupAgent() { } private fun restoreBackupFile(fd: FileDescriptor, size: Long) { - val repository = RestoreRepository(MangaDatabase.create(applicationContext)) + val repository = RestoreRepository(MangaDatabase(applicationContext)) val tempFile = File.createTempFile("backup_", ".tmp") FileInputStream(fd).use { input -> tempFile.outputStream().use { output -> diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt index b41ebb205..53ad51cbc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt @@ -7,12 +7,13 @@ import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug -class BackupSettingsFragment : BasePreferenceFragment(R.string.backup_restore), +class BackupSettingsFragment : + BasePreferenceFragment(R.string.backup_restore), ActivityResultCallback { private val backupSelectCall = registerForActivityResult( @@ -34,9 +35,7 @@ class BackupSettingsFragment : BasePreferenceFragment(R.string.backup_restore), try { backupSelectCall.launch(arrayOf("*/*")) } catch (e: ActivityNotFoundException) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } + e.printStackTraceDebug() Snackbar.make( listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT ).show() diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt index 92fee1db9..4f695b154 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt @@ -18,8 +18,10 @@ import org.koitharu.kotatsu.utils.ext.observeNotNull import org.koitharu.kotatsu.utils.ext.showAllowStateLoss import org.koitharu.kotatsu.utils.ext.withArgs -class OnboardDialogFragment : AlertDialogFragment(), - OnListItemClickListener, DialogInterface.OnClickListener { +class OnboardDialogFragment : + AlertDialogFragment(), + OnListItemClickListener, + DialogInterface.OnClickListener { private val viewModel by viewModel() private var isWelcome: Boolean = false diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt index f2a929017..2f1495243 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt @@ -3,7 +3,6 @@ package org.koitharu.kotatsu.settings.onboard import androidx.collection.ArraySet import androidx.core.os.LocaleListCompat import androidx.lifecycle.MutableLiveData -import java.util.* import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.parsers.model.MangaSource @@ -12,6 +11,7 @@ import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.utils.ext.map import org.koitharu.kotatsu.utils.ext.mapToSet +import java.util.* class OnboardViewModel( private val settings: AppSettings, @@ -55,6 +55,7 @@ class OnboardViewModel( settings.hiddenSources = allSources.filterNot { x -> x.locale in selectedLocales }.mapToSet { x -> x.name } + settings.markKnownSources(settings.newSources) } private fun rebuildList() { diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt index 634eeb757..832922ea3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.list.ui.MangaListViewModel @@ -37,8 +38,10 @@ class SuggestionsViewModel( list.toUi(this, mode) } } + }.onStart { + loadingCounter.increment() }.onFirst { - isLoading.postValue(false) + loadingCounter.decrement() }.catch { it.toErrorState(canRetry = false) }.asLiveDataDistinct( diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt index 8bba4be33..115f41621 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt @@ -10,12 +10,7 @@ import okhttp3.Request import org.json.JSONArray import org.json.JSONObject import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITES -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITE_CATEGORIES -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA_TAGS -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_TAGS +import org.koitharu.kotatsu.core.db.* import org.koitharu.kotatsu.parsers.util.json.mapJSONTo import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.sync.data.AccountAuthenticator diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt index 0c159a53b..02f296aac 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt @@ -8,15 +8,9 @@ import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.net.Uri import androidx.sqlite.db.SupportSQLiteQueryBuilder -import java.util.concurrent.Callable import org.koin.android.ext.android.inject -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITES -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITE_CATEGORIES -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA_TAGS -import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_TAGS +import org.koitharu.kotatsu.core.db.* +import java.util.concurrent.Callable abstract class SyncProvider : ContentProvider() { diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt index a08495f54..975e96d66 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt @@ -1,14 +1,17 @@ package org.koitharu.kotatsu.tracker +import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.ui.FeedViewModel +import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels val trackerModule get() = module { - single { TrackingRepository(get()) } + factory { TrackingRepository(get()) } + factory { TrackerNotificationChannels(androidContext(), get()) } viewModel { FeedViewModel(get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index 661f68bba..aefa9a69a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -2,15 +2,15 @@ package org.koitharu.kotatsu.tracker.domain import androidx.room.withTransaction import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.entity.TrackEntity -import org.koitharu.kotatsu.core.db.entity.TrackLogEntity -import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.db.entity.toTrackingLogItem +import org.koitharu.kotatsu.core.db.entity.* +import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.MangaTracking import org.koitharu.kotatsu.core.model.TrackingLogItem +import org.koitharu.kotatsu.favourites.data.toFavouriteCategory 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 java.util.* class TrackingRepository( @@ -21,16 +21,29 @@ class TrackingRepository( return db.tracksDao.findNewChapters(mangaId) ?: 0 } - suspend fun getAllTracks(useFavourites: Boolean, useHistory: Boolean): List { - val mangaList = ArrayList() - if (useFavourites) { - db.favouritesDao.findAllManga().mapTo(mangaList) { it.toManga(emptySet()) } + suspend fun getHistoryManga(): List { + return db.historyDao.findAllManga().toMangaList() + } + + suspend fun getFavouritesManga(): Map> { + val categories = db.favouriteCategoriesDao.findAll() + return categories.associateTo(LinkedHashMap(categories.size)) { categoryEntity -> + categoryEntity.toFavouriteCategory() to db.favouritesDao.findAllManga(categoryEntity.categoryId).toMangaList() } - if (useHistory) { - db.historyDao.findAllManga().mapTo(mangaList) { it.toManga(emptySet()) } - } - val tracks = db.tracksDao.findAll().groupBy { it.mangaId } - return mangaList + } + + suspend fun getCategoriesCount(): IntArray { + val categories = db.favouriteCategoriesDao.findAll() + return intArrayOf( + categories.count { it.track }, + categories.size, + ) + } + + suspend fun getTracks(mangaList: Collection): List { + val ids = mangaList.mapToSet { it.id } + val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId } + return mangaList // TODO optimize .filterNot { it.source == MangaSource.LOCAL } .distinctBy { it.id } .map { manga -> @@ -103,4 +116,6 @@ class TrackingRepository( ) db.tracksDao.upsert(entity) } + + private fun Collection.toMangaList() = map { it.toManga(emptySet()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt index 292d2c982..0170ee01d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt @@ -65,6 +65,9 @@ class FeedViewModel( if (loadingJob?.isActive == true) { return } + if (append && !hasNextPage.value) { + return + } loadingJob = launchLoadingJob(Dispatchers.Default) { val offset = if (append) logList.value?.size ?: 0 else 0 val list = repository.getTrackingLog(offset, 20) diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt index 74e5468b9..000ff5399 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.tracker.ui.adapter import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import coil.request.Disposable +import coil.size.Scale import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener @@ -34,6 +35,7 @@ fun feedItemAD( .fallback(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder) .allowRgb565(true) + .scale(Scale.FILL) .lifecycle(lifecycleOwner) .enqueueWith(coil) binding.textViewTitle.text = item.title diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index a61b25c47..f1e40a04b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -5,14 +5,16 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.os.Build -import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC +import androidx.core.app.NotificationCompat.VISIBILITY_SECRET import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData import androidx.lifecycle.map import androidx.work.* import coil.ImageLoader import coil.request.ImageRequest +import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent @@ -29,7 +31,6 @@ import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.trySetForeground import org.koitharu.kotatsu.utils.progress.Progress -import java.util.concurrent.TimeUnit class TrackWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams), KoinComponent { @@ -41,26 +42,22 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : private val coil by inject() private val repository by inject() private val settings by inject() + private val channels by inject() override suspend fun doWork(): Result { - val trackSources = settings.trackSources - if (trackSources.isEmpty()) { - return Result.success() - } - val tracks = repository.getAllTracks( - useFavourites = AppSettings.TRACK_FAVOURITES in trackSources, - useHistory = AppSettings.TRACK_HISTORY in trackSources - ) - if (tracks.isEmpty()) { + if (!settings.isTrackerEnabled) { return Result.success() } if (TAG in tags) { // not expedited trySetForeground() } + val tracks = getAllTracks() + var success = 0 val workData = Data.Builder() .putInt(DATA_TOTAL, tracks.size) - for ((index, track) in tracks.withIndex()) { + for ((index, item) in tracks.withIndex()) { + val (track, channelId) = item val details = runCatching { MangaRepository(track.manga.source).getDetails(track.manga) }.getOrNull() @@ -80,12 +77,12 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : track.knownChaptersCount == 0 && track.lastChapterId == 0L -> { // manga was empty on last check repository.storeTrackResult( mangaId = track.manga.id, - knownChaptersCount = track.knownChaptersCount, + knownChaptersCount = 0, lastChapterId = 0L, previousTrackChapterId = track.lastNotifiedChapterId, newChapters = chapters ) - showNotification(details, chapters) + showNotification(details, channelId, chapters) } chapters.size == track.knownChaptersCount -> { if (chapters.lastOrNull()?.id == track.lastChapterId) { @@ -114,7 +111,8 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : ) showNotification( details, - newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId } + channelId, + newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId }, ) } } @@ -126,11 +124,12 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : knownChaptersCount = track.knownChaptersCount, lastChapterId = track.lastChapterId, previousTrackChapterId = track.lastNotifiedChapterId, - newChapters = newChapters + newChapters = newChapters, ) showNotification( - track.manga, - newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId } + manga = track.manga, + channelId = channelId, + newChapters = newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId }, ) } } @@ -144,13 +143,60 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : } } - private suspend fun showNotification(manga: Manga, newChapters: List) { - if (newChapters.isEmpty() || !settings.trackerNotifications) { + private suspend fun getAllTracks(): List { + val sources = settings.trackSources + if (sources.isEmpty()) { + return emptyList() + } + val knownIds = HashSet() + val result = ArrayList() + // Favourites + if (AppSettings.TRACK_FAVOURITES in sources) { + val favourites = repository.getFavouritesManga() + channels.updateChannels(favourites.keys) + for ((category, mangaList) in favourites) { + if (!category.isTrackingEnabled || mangaList.isEmpty()) { + continue + } + val categoryTracks = repository.getTracks(mangaList) + val channelId = if (channels.isFavouriteNotificationsEnabled(category)) { + channels.getFavouritesChannelId(category.id) + } else { + null + } + for (track in categoryTracks) { + if (knownIds.add(track.manga)) { + result.add(TrackingItem(track, channelId)) + } + } + } + } + // History + if (AppSettings.TRACK_HISTORY in sources) { + val history = repository.getHistoryManga() + val historyTracks = repository.getTracks(history) + val channelId = if (channels.isHistoryNotificationsEnabled()) { + channels.getHistoryChannelId() + } else { + null + } + for (track in historyTracks) { + if (knownIds.add(track.manga)) { + result.add(TrackingItem(track, channelId)) + } + } + } + result.trimToSize() + return result + } + + private suspend fun showNotification(manga: Manga, channelId: String?, newChapters: List) { + if (newChapters.isEmpty() || channelId == null) { return } val id = manga.url.hashCode() val colorPrimary = ContextCompat.getColor(applicationContext, R.color.blue_primary) - val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + val builder = NotificationCompat.Builder(applicationContext, channelId) val summary = applicationContext.resources.getQuantityString( R.plurals.new_chapters, newChapters.size, newChapters.size @@ -183,6 +229,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : ) ) setAutoCancel(true) + setVisibility(if (manga.isNsfw) VISIBILITY_SECRET else VISIBILITY_PUBLIC) color = colorPrimary setShortcutId(manga.id.toString()) priority = NotificationCompat.PRIORITY_DEFAULT @@ -236,7 +283,6 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : companion object { - const val CHANNEL_ID = "tracking" private const val WORKER_CHANNEL_ID = "track_worker" private const val WORKER_NOTIFICATION_ID = 35 private const val DATA_PROGRESS = "progress" @@ -244,27 +290,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) : private const val TAG = "tracking" private const val TAG_ONESHOT = "tracking_oneshot" - @RequiresApi(Build.VERSION_CODES.O) - private fun createNotificationChannel(context: Context) { - val manager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (manager.getNotificationChannel(CHANNEL_ID) == null) { - val channel = NotificationChannel( - CHANNEL_ID, - context.getString(R.string.new_chapters), - NotificationManager.IMPORTANCE_DEFAULT - ) - channel.setShowBadge(true) - channel.lightColor = ContextCompat.getColor(context, R.color.blue_primary_dark) - channel.enableLights(true) - manager.createNotificationChannel(channel) - } - } - fun setup(context: Context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(context) - } val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt new file mode 100644 index 000000000..81fcb73d5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt @@ -0,0 +1,143 @@ +package org.koitharu.kotatsu.tracker.work + +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationManagerCompat +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.prefs.AppSettings + +class TrackerNotificationChannels( + private val context: Context, + private val settings: AppSettings, +) { + + private val manager = NotificationManagerCompat.from(context) + + val areNotificationsDisabled: Boolean + get() = !manager.areNotificationsEnabled() + + fun updateChannels(categories: Collection) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + manager.deleteNotificationChannel(OLD_CHANNEL_ID) + val group = createGroup() + val existingChannels = group.channels.associateByTo(HashMap()) { it.id } + for (category in categories) { + val id = getFavouritesChannelId(category.id) + if (existingChannels.remove(id)?.name == category.title) { + continue + } + val channel = NotificationChannel(id, category.title, NotificationManager.IMPORTANCE_DEFAULT) + channel.group = GROUP_ID + manager.createNotificationChannel(channel) + } + existingChannels.remove(CHANNEL_ID_HISTORY) + createHistoryChannel() + for (id in existingChannels.keys) { + manager.deleteNotificationChannel(id) + } + } + + fun createChannel(category: FavouriteCategory) { + renameChannel(category.id, category.title) + } + + fun renameChannel(categoryId: Long, name: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val id = getFavouritesChannelId(categoryId) + val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT) + channel.group = createGroup().id + manager.createNotificationChannel(channel) + } + + fun deleteChannel(categoryId: Long) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + manager.deleteNotificationChannel(getFavouritesChannelId(categoryId)) + } + + fun isFavouriteNotificationsEnabled(category: FavouriteCategory): Boolean { + if (!manager.areNotificationsEnabled()) { + return false + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = manager.getNotificationChannel(getFavouritesChannelId(category.id)) + channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE + } else { + // fallback + settings.isTrackerNotificationsEnabled + } + } + + fun isHistoryNotificationsEnabled(): Boolean { + if (!manager.areNotificationsEnabled()) { + return false + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = manager.getNotificationChannel(getHistoryChannelId()) + channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE + } else { + // fallback + settings.isTrackerNotificationsEnabled + } + } + + fun isNotificationGroupEnabled(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return settings.isTrackerNotificationsEnabled + } + val group = manager.getNotificationChannelGroup(GROUP_ID) ?: return true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && group.isBlocked) { + return false + } + return group.channels.any { it.importance != NotificationManagerCompat.IMPORTANCE_NONE } + } + + fun getFavouritesChannelId(categoryId: Long): String { + return CHANNEL_ID_PREFIX + categoryId + } + + fun getHistoryChannelId(): String { + return CHANNEL_ID_HISTORY + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createGroup(): NotificationChannelGroup { + manager.getNotificationChannelGroup(GROUP_ID)?.let { + return it + } + val group = NotificationChannelGroup(GROUP_ID, context.getString(R.string.new_chapters)) + manager.createNotificationChannelGroup(group) + return group + } + + private fun createHistoryChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val channel = NotificationChannel( + CHANNEL_ID_HISTORY, + context.getString(R.string.history), + NotificationManager.IMPORTANCE_DEFAULT, + ) + channel.group = GROUP_ID + manager.createNotificationChannel(channel) + } + + companion object { + + const val GROUP_ID = "trackers" + private const val CHANNEL_ID_PREFIX = "track_fav_" + private const val CHANNEL_ID_HISTORY = "track_history" + private const val OLD_CHANNEL_ID = "tracking" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackingItem.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackingItem.kt new file mode 100644 index 000000000..933918009 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackingItem.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.tracker.work + +import org.koitharu.kotatsu.core.model.MangaTracking + +class TrackingItem( + val tracking: MangaTracking, + val channelId: String?, +) { + + operator fun component1() = tracking + + operator fun component2() = channelId + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TrackingItem + + if (tracking != other.tracking) return false + if (channelId != other.channelId) return false + + return true + } + + override fun hashCode(): Int { + var result = tracking.hashCode() + result = 31 * result + channelId.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt b/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt index 17d62d3a3..ae40b41f6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt @@ -2,15 +2,16 @@ package org.koitharu.kotatsu.utils import android.view.View import androidx.appcompat.widget.Toolbar -import com.google.android.material.R as materialR import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.R as materialR open class BottomSheetToolbarController( protected val toolbar: Toolbar, ) : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_EXPANDED) { + val isExpanded = newState == BottomSheetBehavior.STATE_EXPANDED && bottomSheet.top <= 0 + if (isExpanded) { toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material) } else { toolbar.navigationIcon = null diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt b/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt index 03dd423ea..cedc875fa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt @@ -10,7 +10,7 @@ import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class LifecycleAwareServiceConnection private constructor( +class LifecycleAwareServiceConnection( private val host: Activity, ) : ServiceConnection, DefaultLifecycleObserver { @@ -31,19 +31,15 @@ class LifecycleAwareServiceConnection private constructor( super.onDestroy(owner) host.unbindService(this) } +} - companion object { - - fun bindService( - host: Activity, - lifecycleOwner: LifecycleOwner, - service: Intent, - flags: Int, - ): LifecycleAwareServiceConnection { - val connection = LifecycleAwareServiceConnection(host) - host.bindService(service, connection, flags) - lifecycleOwner.lifecycle.addObserver(connection) - return connection - } - } +fun Activity.bindServiceWithLifecycle( + owner: LifecycleOwner, + service: Intent, + flags: Int +): LifecycleAwareServiceConnection { + val connection = LifecycleAwareServiceConnection(this) + bindService(service, connection, flags) + owner.lifecycle.addObserver(connection) + return connection } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt b/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt new file mode 100644 index 000000000..e95e0fb96 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt @@ -0,0 +1,26 @@ +package org.koitharu.kotatsu.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.speech.RecognizerIntent +import androidx.activity.result.contract.ActivityResultContract + +class VoiceInputContract : ActivityResultContract() { + + override fun createIntent(context: Context, input: String?): Intent { + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + intent.putExtra(RecognizerIntent.EXTRA_PROMPT, input) + return intent + } + + override fun parseResult(resultCode: Int, intent: Intent?): String? { + return if (resultCode == Activity.RESULT_OK && intent != null) { + val matches = intent.getStringArrayExtra(RecognizerIntent.EXTRA_RESULTS) + matches?.firstOrNull() + } else { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index f9f2452ba..ee6b5b30f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -3,18 +3,25 @@ package org.koitharu.kotatsu.utils.ext import android.content.Context import android.content.OperationApplicationException import android.content.SyncResult +import android.content.pm.ResolveInfo import android.database.SQLException import android.net.ConnectivityManager import android.net.Network import android.net.NetworkRequest import android.net.Uri import android.os.Build +import androidx.activity.result.ActivityResultLauncher +import androidx.core.app.ActivityOptionsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope import androidx.work.CoroutineWorker +import kotlin.coroutines.resume +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import okio.IOException import org.json.JSONException import org.koitharu.kotatsu.BuildConfig -import kotlin.coroutines.resume val Context.connectivityManager: ConnectivityManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager @@ -48,6 +55,25 @@ suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching { setForeground(info) }.isSuccess +fun ActivityResultLauncher.resolve(context: Context, input: I): ResolveInfo? { + val pm = context.packageManager + val intent = contract.createIntent(context, input) + return pm.resolveActivity(intent, 0) +} + +fun ActivityResultLauncher.tryLaunch(input: I, options: ActivityOptionsCompat? = null): Boolean { + return runCatching { + launch(input, options) + }.isSuccess +} + +fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) { + coroutineScope.launch { + delay(delay) + runnable.run() + } +} + fun SyncResult.onError(error: Throwable) { when (error) { is IOException -> stats.numIoExceptions++ diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 04bc82e1d..0ab153da6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -3,10 +3,6 @@ package org.koitharu.kotatsu.utils.ext import androidx.collection.ArraySet import java.util.* -fun > Array.names() = Array(size) { i -> - this[i].name -} - fun MutableList.move(sourceIndex: Int, targetIndex: Int) { if (sourceIndex <= targetIndex) { Collections.rotate(subList(sourceIndex, targetIndex + 1), -1) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt index 412ead77c..dd4907134 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt @@ -3,15 +3,6 @@ package org.koitharu.kotatsu.utils.ext import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.CoroutineExceptionHandler -import org.koitharu.kotatsu.BuildConfig - -val IgnoreErrors - get() = CoroutineExceptionHandler { _, e -> - if (BuildConfig.DEBUG) { - e.printStackTrace() - } - } val processLifecycleScope: LifecycleCoroutineScope inline get() = ProcessLifecycleOwner.get().lifecycleScope \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt index ad1fe8b39..e3cd66b43 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.liveData +import kotlinx.coroutines.Deferred import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt index eb38e1d32..317c77c5f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt @@ -1,7 +1,8 @@ package org.koitharu.kotatsu.utils.progress +import coil.request.ErrorResult import coil.request.ImageRequest -import coil.request.ImageResult +import coil.request.SuccessResult import com.google.android.material.progressindicator.BaseProgressIndicator class ImageRequestIndicatorListener( @@ -10,9 +11,9 @@ class ImageRequestIndicatorListener( override fun onCancel(request: ImageRequest) = indicator.hide() - override fun onError(request: ImageRequest, throwable: Throwable) = indicator.hide() + override fun onError(request: ImageRequest, result: ErrorResult) = indicator.hide() override fun onStart(request: ImageRequest) = indicator.show() - override fun onSuccess(request: ImageRequest, metadata: ImageResult.Metadata) = indicator.hide() + override fun onSuccess(request: ImageRequest, result: SuccessResult) = indicator.hide() } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bookmark.xml b/app/src/main/res/drawable/ic_bookmark.xml new file mode 100644 index 000000000..f5457e1bd --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bookmark_added.xml b/app/src/main/res/drawable/ic_bookmark_added.xml new file mode 100644 index 000000000..9ebb3889b --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_added.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_voice_input.xml b/app/src/main/res/drawable/ic_voice_input.xml new file mode 100644 index 000000000..ab46188aa --- /dev/null +++ b/app/src/main/res/drawable/ic_voice_input.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-w600dp/fragment_details.xml b/app/src/main/res/layout-w600dp/fragment_details.xml index 936a0ac6c..6a862073a 100644 --- a/app/src/main/res/layout-w600dp/fragment_details.xml +++ b/app/src/main/res/layout-w600dp/fragment_details.xml @@ -157,6 +157,37 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/layout_titles" /> + + + + @@ -189,6 +220,14 @@ app:showAnimationBehavior="inward" tools:visibility="visible" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout-w720dp-land/activity_main.xml b/app/src/main/res/layout-w720dp-land/activity_main.xml index 80a34cfb5..a442269b3 100644 --- a/app/src/main/res/layout-w720dp-land/activity_main.xml +++ b/app/src/main/res/layout-w720dp-land/activity_main.xml @@ -31,8 +31,9 @@ android:id="@+id/appbar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingLeft="16dp" android:background="@null" + android:clipToPadding="false" + android:paddingLeft="16dp" android:paddingRight="16dp" app:elevation="0dp" app:liftOnScroll="false"> @@ -61,6 +62,7 @@ android:layout_height="match_parent" android:background="@null" android:drawablePadding="16dp" + android:layout_marginEnd="4dp" android:gravity="center_vertical" android:hint="@string/search_manga" android:imeOptions="actionSearch" diff --git a/app/src/main/res/layout/activity_category_edit.xml b/app/src/main/res/layout/activity_category_edit.xml new file mode 100644 index 000000000..e8b9d8eab --- /dev/null +++ b/app/src/main/res/layout/activity_category_edit.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1366af2c4..6314cf7ef 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -23,6 +23,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@null" + android:clipToPadding="false" android:paddingLeft="16dp" android:paddingRight="16dp" android:stateListAnimator="@null"> @@ -50,13 +51,12 @@ style="@style/Widget.Kotatsu.SearchView" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginEnd="2dp" + android:layout_marginEnd="4dp" android:background="@null" android:gravity="center_vertical" android:hint="@string/search_manga" android:imeOptions="actionSearch" android:importantForAutofill="no" - android:paddingBottom="1dp" android:singleLine="true" tools:drawableEnd="@drawable/abc_ic_clear_material" /> diff --git a/app/src/main/res/layout/dialog_favorite_categories.xml b/app/src/main/res/layout/dialog_favorite_categories.xml index 2085c72f7..91cc561aa 100644 --- a/app/src/main/res/layout/dialog_favorite_categories.xml +++ b/app/src/main/res/layout/dialog_favorite_categories.xml @@ -5,13 +5,14 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical"> + android:orientation="vertical" + android:paddingBottom="@dimen/list_spacing"> + + diff --git a/app/src/main/res/layout/fragment_details.xml b/app/src/main/res/layout/fragment_details.xml index e001bb43d..148179075 100644 --- a/app/src/main/res/layout/fragment_details.xml +++ b/app/src/main/res/layout/fragment_details.xml @@ -161,6 +161,37 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/button_read" /> + + + + @@ -193,6 +224,14 @@ app:showAnimationBehavior="inward" tools:visibility="visible" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_favourites.xml b/app/src/main/res/layout/fragment_favourites.xml index 0f7e61994..d93d500b3 100644 --- a/app/src/main/res/layout/fragment_favourites.xml +++ b/app/src/main/res/layout/fragment_favourites.xml @@ -17,4 +17,10 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_bookmark.xml b/app/src/main/res/layout/item_bookmark.xml new file mode 100644 index 000000000..78aab3400 --- /dev/null +++ b/app/src/main/res/layout/item_bookmark.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_checkable_new.xml b/app/src/main/res/layout/item_checkable_new.xml index 9ec3eacf1..6e523dad8 100644 --- a/app/src/main/res/layout/item_checkable_new.xml +++ b/app/src/main/res/layout/item_checkable_new.xml @@ -1,7 +1,6 @@ \ No newline at end of file diff --git a/app/src/main/res/layout/preference_toggle_header.xml b/app/src/main/res/layout/preference_toggle_header.xml new file mode 100644 index 000000000..a95b5e211 --- /dev/null +++ b/app/src/main/res/layout/preference_toggle_header.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/opt_favourites_bs.xml b/app/src/main/res/menu/opt_favourites_bs.xml deleted file mode 100644 index 56e7d5723..000000000 --- a/app/src/main/res/menu/opt_favourites_bs.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/opt_favourites_list.xml b/app/src/main/res/menu/opt_favourites_list.xml new file mode 100644 index 000000000..269569df7 --- /dev/null +++ b/app/src/main/res/menu/opt_favourites_list.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/opt_reader_bottom.xml b/app/src/main/res/menu/opt_reader_bottom.xml index b56029f73..569bffc11 100644 --- a/app/src/main/res/menu/opt_reader_bottom.xml +++ b/app/src/main/res/menu/opt_reader_bottom.xml @@ -5,6 +5,20 @@ xmlns:tools="http://schemas.android.com/tools" tools:ignore="AlwaysShowAction"> + + + + - - + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/popup_category.xml b/app/src/main/res/menu/popup_category.xml index 1c4fc96d2..50c1313be 100644 --- a/app/src/main/res/menu/popup_category.xml +++ b/app/src/main/res/menu/popup_category.xml @@ -7,20 +7,7 @@ android:title="@string/remove" /> - - - - - - - - - + android:id="@+id/action_edit" + android:title="@string/edit" /> \ No newline at end of file diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index cbd9de9a7..bada7be01 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -101,7 +101,6 @@ Паведамленні Уключана %1$d з %2$d Новыя главы - Апавяшчаць пра абнаўленні мангі, якую вы чытаеце Спампаваць Чытаць з пачатку Перазапусціць diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 42fb3a9ee..a8c8d8feb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -46,7 +46,6 @@ Aktualisierungsfeed gelöscht Aktualisierungsfeed löschen Aktualisierungen - Über Aktualisierungen von Manga benachrichtigen, die du liest Benachrichtigung anzeigen, wenn eine Aktualisierung verfügbar ist Anwendungsaktualisierung ist verfügbar Automatisch nach Aktualisierungen suchen @@ -278,4 +277,5 @@ Hilft, das Blockieren Ihrer IP-Adresse zu vermeiden Die Kapitel werden im Hintergrund entfernt. Das kann einige Zeit dauern Ausblenden + Neue Manga-Quellen sind verfügbar \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9cde0edc3..ef974cffd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -101,7 +101,6 @@ Notificaciones Activado %1$d de %2$d Nuevos capítulos - Notificar sobre las actualizaciones del manga que estás leyendo Descargar Leer desde el principio Reiniciar diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 69f9c193e..2afc820b9 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -118,7 +118,6 @@ Käynnistä uudelleen Lue alusta Lataa - Ilmoita lukemastasi mangan päivityksistä Uusia lukuja Käytössä %1$d / %2$d Ilmoitukset @@ -278,4 +277,5 @@ Oletko varma, että haluat ladata kaikki valitut mangat kaikkine lukuineen\? Tämä toiminto voi kuluttaa paljon liikennettä ja tallennustilaa Tallennettujen mangojen käsittely Piilota + Uusia mangalähteitä on saatavilla \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 958794036..a70284d3f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -112,7 +112,6 @@ Paramètres des notifications Lire depuis le début Télécharger - Avertir des mises à jour des mangas que vous lisez Nouveaux chapitres %1$d de %2$d activé(s) Notifications @@ -278,4 +277,5 @@ Les chapitres seront supprimés en arrière-plan. Cela peut prendre un certain temps Traitement des mangas sauvegardés Masquer + De nouvelles sources de mangas sont disponibles \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e6196111b..65fad3ab8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -154,7 +154,6 @@ Riavvia Leggi dall\'inizio Scarica - Notifica gli aggiornamenti dei manga che stai leggendo Nuovi capitoli Abilitato %1$d di %2$d Notifiche @@ -278,4 +277,5 @@ I capitoli saranno rimossi in sfondo. Può richiedere un po\' di tempo Aiuta ad evitare il blocco del tuo indirizzo IP Nascondi + Sono disponibili nuove fonti di manga \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d6db95de8..c8066422c 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -143,7 +143,6 @@ 検索履歴をクリア 外部ストレージ Kotatsuの新しい更新が利用可能です - あなたが読んでいる漫画の更新について通知 ここは空っぽです… カテゴリーを使用してお気に入りを整理できます。 «+»を押してカテゴリーを作成出来ます 空のカテゴリー @@ -278,4 +277,5 @@ 並列ダウンロード チャプターはバックグラウンドで削除されます。時間がかかる場合があります 隠す + 新しいマンガソースが利用可能になりました \ No newline at end of file diff --git a/app/src/main/res/values-large/themes.xml b/app/src/main/res/values-large/themes.xml new file mode 100644 index 000000000..56e1ed965 --- /dev/null +++ b/app/src/main/res/values-large/themes.xml @@ -0,0 +1,34 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 74c07b980..41444062e 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -54,7 +54,6 @@ Fjern «%s»-kategorien fra favorittene\? \nAlle mangaer i den vil bli tapt. Programomstart - Gi merknad om oppdateringer av det du leser %1$d av %2$d påskrudd Denne mangaen har %s. Lagre hele\? Åpne i nettleser diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 32910e0a3..2af8ed7e3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -232,7 +232,6 @@ Limpar cache de miniaturas Verifique se há novas versões do aplicativo Categorias favoritas - Notificar sobre atualizações de mangá que você está lendo Remover a categoria “%s” dos seus favoritos\? \nTodos os mangás nela serão perdidos. Nenhuma atualização disponível @@ -278,4 +277,5 @@ Processamento de mangá salvo Os capítulos serão removidos em segundo plano. Pode levar algum tempo Downloads paralelos + Novas fontes de mangá estão disponíveis \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index b23edc825..d3794d77a 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -83,7 +83,6 @@ Salve Notificações Novos capítulos - Notifique sobre atualizações do mangá que está lendo Download Ler desde o início Reiniciar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 41dad2021..0b4fa423e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -101,7 +101,6 @@ Уведомления Включено %1$d из %2$d Новые главы - Уведомлять об обновлении манги, которую Вы читаете Загрузить Читать с начала Перезапустить @@ -278,4 +277,12 @@ Главы будут удалены в фоновом режиме. Это может занять какое-то время Скрыть Доступны новые источники манги + Проверять новые главы и уведомлять о них + Вы будете получать уведомления об обновлении манги, которую Вы читаете + Вы не будете получать уведомления, но новые главы будут отображаться в списке + Включить уведомления + Название + Изменить + Изменить категорию + Нет категорий избранного \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 76cfa061a..264ad6c1c 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -105,7 +105,6 @@ Ladda ned Aviseringsinställningar LED-indikator - Avisera om uppdateringar på manga du läser Läs från början Starta om Aviseringsljud diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index ff48edbc1..0580f512c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -234,7 +234,6 @@ Öntanımlı Bir ad girmelisiniz %s üzerinde oturum açma desteklenmiyor - Okunan manga güncellemeleri hakkında bildirimde bulun Daha fazla oku Bazı aygıtların arka plan görevlerini bozabilecek farklı sistem davranışları vardır. Ekran görüntüsü politikası @@ -278,4 +277,5 @@ IP adresinizin engellenmesinden kaçınmanıza yardımcı olur Kaydedilen manga işleme Gizle + Yeni manga kaynakları var \ No newline at end of file diff --git a/app/src/main/res/values-uk/plurals.xml b/app/src/main/res/values-uk/plurals.xml new file mode 100644 index 000000000..0ced943b7 --- /dev/null +++ b/app/src/main/res/values-uk/plurals.xml @@ -0,0 +1,51 @@ + + + + %1$d новий розділ + %1$d нових розділи + %1$d нових розділів + %1$d нових розділів + + + %1$d хвилину тому + %1$d хвилини тому + %1$d хвилин тому + %1$d хвилин тому + + + Всього %1$d сторінка + Всього %1$d сторінки + Всього %1$d сторінок + Всього %1$d сторінок + + + %1$d годину тому + %1$d години тому + %1$d годин тому + %1$d годин тому + + + %1$d день тому + %1$d дні тому + %1$d днів тому + %1$d днів тому + + + %1$d елемент + %1$d елементи + %1$d елементів + %1$d елементів + + + %1$d розділ із %2$d + %1$d розділи з %2$d + %1$d розділів із %2$d + %1$d розділів із %2$d + + + %1$d розділ + %1$d розділи + %1$d розділів + %1$d розділів + + \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 000000000..e476c3a7b --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,282 @@ + + + Дочекайтеся завершення завантаження… + Видалити + Нічого не знайдено + Додати це до улюблених + Очистити історію + Історії ще немає + Додати + Зберегти + Локальне сховище + Не вдалося підключитися до Інтернету + Деталі + Спробуйте ще раз + Відкрити меню + Улюблених ще немає + Нова категорія + Введіть назву категорії + Завантажено + Уподобання + Історія + Сталася помилка + Розділи + Список + Детальний список + Режим списку + Налаштування + Віддалені джерела + Завантаження… + Обчислення… + Розділ %1$d із %2$d + Закрити + Читати + Таблиця + Поділитися + Створити ярлик… + Поділитися %s + Пошук + Пошук манґи + Обробка… + Ім\'я + Популярна + Оновлена + Нова + Рейтинг + Порядок сортування + Фільтр + Тема + Світла + Темна + Сторінки + Очистити всю історію читання перманентно\? + Видалити + \"%s\" видалено з історії + \"%s\" видалено з локального сховища + Зберегти сторінку + Збережено + Поділитись зображенням + Ця операція не підтримується + Виберіть файл ZIP або CBZ. + Немає опису + Історія та кеш + Очистити кеш сторінок + Кеш + Б|кБ|МБ|ГБ|ТБ + Стандартний + Манхва + Режим читання + Розмір сітки + Пошук по %s + Видалити манґу + Видалити \"%s\" з пристрою перманентно\? + Налаштування читача + Перегортання сторінок + Кнопки гучності + Скасування… + Помилка + Очистити кеш мініатюр + Очистити історію пошуку + Очищено + Тільки жести + Внутрішнє сховище + Зовнішнє сховище + Домен + Перевірити наявність нових версій додатка + Доступна нова версія додатка + Ця манґа має %s. Зберегти все це\? + Зберегти + Сповіщення + Увімкнено %1$d з %2$d + Нові розділи + Повідомляти про оновлення манґи, яку Ви читаєте + Завантажити + Читати з початку + Перезавантажити + Вібрація + Улюблені категорії + Вилучити категорію \"%s\" зі своїх уподобань\? +\nВся манґа в ній буде втрачена. + Видалити + Тут якось пусто… + Спробуйте переформулювати запит. + Те, що ви читаєте, буде показано тут + Знайдіть, що читати, у бічному меню. + Спочатку збережіть щось + Збережіть його з онлайн-джерела або імпортуйте файли. + Полиця + Недавні + Анімація перегортання + Папка для завантажень + Інше сховище + Готово + Усі улюблені + Порожня категорія + Прочитати пізніше + Оновлення + Схожі + Нова версія: %s + Розмір: %s + Очікування мережі… + Очистити стрічку оновлень + Очищено + Повернути екран + Оновити + Оновлення скоро почнеться + Стежити за оновленнями + Не перевіряти + Неправильний пароль + Захистити додаток + Запитувати пароль під час запуску Kotatsu + Повторіть пароль + Паролі не співпадають + Про програму + Версія %s + Перевірити наявність оновлень + Перевірка наявності оновлень… + Не вдалося перевірити оновлення + Немає доступних оновлень + Віддавати перевагу читанню справа наліво (←) + Нова категорія + Режим масштабування + Вмістити в екран + Підігнати по висоті + підігнати по ширині + Вихідний розмір + Чорна + Споживає менше енергії на екранах AMOLED + Потрібен перезапуск + Резервне копіювання та відновлення + Відновлено + Підготовка… + Створити проблему на GitHub + Файл не знайдено + Дані відновлено, але є деякі помилки + Ви можете створити резервну копію своєї історії та уподобань і відновити їх + Тільки що + Торкніться, щоб спробувати ще раз + Обраний режим буде запам\'ятован для цієї манги + Потрібна CAPTCHA + Пройти + Очистити кукі + Всі кукі були видалені + Очистити стрічку + Перевірити нові розділи + В зворотньому порядку + Увійти + Увійдіть, щоб переглянути цей вміст + За замовчуванням: %s + ...і ще %1$d + Далі + Введіть пароль для запуску програми + Підтвердити + Пароль має містити 4 символи або більше + Пошук лише на %s + Ласкаво просимо + Резервна копія збережена + Докладніше + У черзі + Немає активних завантажень + Допомогти з перекладом програми + Переклад + Тема на 4PDA + Авторизація виконана + Вхід на %s не підтримується + Ви вийдете з усіх джерел + Завершена + Триває + Формат дати + Виключити NSFW манґу з історії + Ви повинні ввести ім’я + Показувати номери сторінок + Включені джерела + Застосовує тему програми, засновану на палітрі кольорів шпалер на пристрої + Імпорт манґи: %1$d з %2$d + Політика щодо знімків екрана + Дозволити + Пропонувати манґу на основі ваших уподобань + Усі дані аналізуються локально на цьому пристрої. Передача ваших персональних даних у будь-які сервіси не здійснюється + Почніть читати манґу, і ви отримаєте персоналізовані пропозиції + Увімкнено + Вимкнено + Скинути фільтр + Знайти жанр + Виберіть мови, якими ви хочете читати манґу. Це можливо змінити пізніше в налаштуваннях. + Тільки по Wi-Fi + Попереднє завантаження сторінок + Ви увійшли як %s + 18+ + Різні мови + Знайти розділ + Немає розділів у цій манзі + %1$s%% + Зміст + Оновлення пропозицій + Видалити вибрані елементи з пристрою назавжди\? + Видалення завершено + Ви впевнені, що хочете завантажити всю вибрану манґу з усіма її розділами\? Це може споживати багато трафіку та пам’яті + Завантажувати паралельно + Сповільнення завантаження + Обробка збереженої манґи + Приховати + Доступні нові джерела манґи + Закрити меню + Завантаження… + Очистити + Завантаження + Як в системі + Завантажте або прочитайте цей відсутній розділ онлайн. + Розділ відсутній + Зворотній зв\'язок + Жанри + За замовчуванням + Завжди + Продовжити + Імпорт + Натискання по краях + Попередження + Це може призвести до витрати великої кількості трафіку + Більше не питати + Налаштування сповіщень + Перейменувати + Показувати сповіщення, якщо доступна нова версія + Відкрити у веб-браузері + Недоступно + Немає доступного сховища + Нові розділи того, що ви читаєте, показано тут + Результати пошуку + Введіть пароль + Звук сповіщень + Світлодіодний індикатор + Категорії… + Ви можете використовувати категорії для впорядкування своїх уподобань. Натисніть «+», щоб створити категорію + Учора + Справа наліво (←) + Режим читання можна налаштувати окремо для кожної серії + Створити резервну копію + Відновити з резервної копії + Всі дані були відновлені + Групувати + Сьогодні + Без звуку + Давно + Перевірка наявності нових розділів: %1$d з %2$d + Очистити всю історію оновлень назавжди\? + Деякі пристрої мають різну поведінку системи, що може порушити фонові завдання. + Видалити всі останні пошукові запити назавжди\? + Інше + Доступні джерела + Динамічна тема + Блок на NSFW + Завжди блокувати + Пропозиції + Увімкнути пропозиції + Не пропонувати NSFW манґу + Не вдалося завантажити список жанрів + Ніколи + Зовнішній вигляд + Виключити жанри + Укажіть жанри, які ви не хочете бачити в пропозиціях + Допомагає уникнути блокування вашої IP-адреси + Розділи будуть видалені у фоновому режимі. Це може зайняти деякий час + \ No newline at end of file diff --git a/app/src/main/res/values-v27/styles.xml b/app/src/main/res/values-v27/styles.xml new file mode 100644 index 000000000..370045cf3 --- /dev/null +++ b/app/src/main/res/values-v27/styles.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 03bed46af..176b70687 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -34,4 +34,15 @@ @string/only_using_wifi @string/never + + @string/disabled + Google + CloudFlare + AdGuard + + + @string/standard + @string/right_to_left + @string/webtoon + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index c14ddff76..348cff5e0 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -26,8 +26,7 @@ - - + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 282d15034..20debe81b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -13,6 +13,8 @@ 2dp 86dp 120dp + 120dp + 4dp 62dp 120dp 48dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19b95af7e..a0e4737a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,242 +1,239 @@ Kotatsu - Close menu - Open menu - Local storage - Favourites - History - An error occurred - Could not connect to the Internet - Details - Chapters - List - Detailed list - Grid - List mode - Settings - Remote sources - Loading… - Computing… - Chapter %1$d of %2$d - Close - Try again - Clear history - Nothing found - No history yet - Read - No favourites yet - Favourite this - New category - Add - Enter category name - Save - Share - Create shortcut… - Share %s - Search - Search manga - Downloading… - Processing… - Downloaded - Downloads - Name - Popular - Updated - Newest - Rating - Sorting order - Filter - Theme - Light - Dark - Follow system - Pages - Clear - Clear all reading history permanently? - Remove - \"%s\" removed from history - \"%s\" deleted from local storage - Wait for loading to finish… - Save page - Saved - Share image - Import - Delete - This operation is not supported - Either pick a ZIP or CBZ file. - No description - History and cache - Clear page cache - Cache - B|kB|MB|GB|TB - Standard - Webtoon - Read mode - Grid size - Search on %s - Delete manga - Delete \"%s\" from device permanently? - Reader settings - Switch pages - Edge taps - Volume buttons - Continue - Warning - This may transfer a lot of data - Don\'t ask again - Cancelling… - Error - Clear thumbnails cache - Clear search history - Cleared - Gestures only - Internal storage - External storage - Domain - Check for new versions of the app - A new version of the app is available - Show notification if a new version is available - Open in web browser - This manga has %s. Save all of it? - Save - Notifications - %1$d of %2$d on - New chapters - Notify about updates of manga you are reading - Download - Read from start - Restart - Notifications settings - Notification sound - LED indicator - Vibration - Favourite categories - Categories… - Rename - Remove the \"%s\" category from your favourites? \nAll manga in it will be lost. - Remove - It\'s kind of empty here… - You can use categories to organize your favourites. Press «+» to create a category - Try to reformulate the query. - What you read will be displayed here - Find what to read in side menu. - Save something first - Save it from online sources or import files. - Shelf - Recent - Page animation - Folder for downloads - Not available - No available storage - Other storage + Close menu + Open menu + Local storage + Favourites + History + An error occurred + Could not connect to the Internet + Details + Chapters + List + Detailed list + Grid + List mode + Settings + Remote sources + Loading… + Computing… + Chapter %1$d of %2$d + Close + Try again + Clear history + Nothing found + No history yet + Read + No favourites yet + Favourite this + New category + Add + Enter category name + Save + Share + Create shortcut… + Share %s + Search + Search manga + Downloading… + Processing… + Downloaded + Downloads + Name + Popular + Updated + Newest + Rating + Sorting order + Filter + Theme + Light + Dark + Follow system + Pages + Clear + Clear all reading history permanently? + Remove + \"%s\" removed from history + \"%s\" deleted from local storage + Wait for loading to finish… + Save page + Saved + Share image + Import + Delete + This operation is not supported + Either pick a ZIP or CBZ file. + No description + History and cache + Clear page cache + Cache + B|kB|MB|GB|TB + Standard + Webtoon + Read mode + Grid size + Search on %s + Delete manga + Delete \"%s\" from device permanently? + Reader settings + Switch pages + Edge taps + Volume buttons + Continue + Warning + This may transfer a lot of data + Don\'t ask again + Cancelling… + Error + Clear thumbnails cache + Clear search history + Cleared + Gestures only + Internal storage + External storage + Domain + Check for new versions of the app + A new version of the app is available + Show notification if a new version is available + Open in web browser + This manga has %s. Save all of it? + Save + Notifications + %1$d of %2$d on + New chapters + Download + Read from start + Restart + Notifications settings + Notification sound + LED indicator + Vibration + Favourite categories + Categories… + Rename + Remove the \"%s\" category from your favourites? \nAll manga in it will be lost. + Remove + It\'s kind of empty here… + You can use categories to organize your favourites. Press «+» to create a category + Try to reformulate the query. + What you read will be displayed here + Find what to read in side menu. + Save something first + Save it from online sources or import files. + Shelf + Recent + Page animation + Folder for downloads + Not available + No available storage + Other storage Done - All favourites - Empty category - Read later - Updates - New chapters of what you are reading is shown here - Search results - Related - New version: %s - Size: %s - Waiting for network… - Clear updates feed - Cleared - Rotate screen - Update - Feed update will start soon - Look for updates - Don\'t check - Enter password - Wrong password - Protect the app - Ask for password when starting Kotatsu - Repeat the password - Mismatching passwords - About - Version %s - Check for updates - Checking for updates… - Could not look for updates - No updates available - Right-to-left (←) - Prefer right-to-left (←) reader - Reading mode can be set up separately for each series - New category - Scale mode - Fit center - Fit to height - Fit to width - Keep at start - Black - Uses less power on AMOLED screens - Restart required - Backup and restore - Create data backup - Restore from backup - Restored - Preparing… - Create issue on GitHub - File not found - All data was restored - The data was restored, but there are errors - You can create backup of your history and favourites and restore it - Just now - Yesterday - Long ago - Group - Today - Tap to try again - The chosen configuration will be remembered for this manga - Silent - CAPTCHA required - Solve - Clear cookies - All cookies were removed - Checking for new chapters: %1$d of %2$d - Clear feed - Clear all update history permanently? - Check for new chapters - Reverse - Sign in - Sign in to view this content - Default: %s - …and %1$d more - Next - Enter a password to start the app with - Confirm - The password must be 4 characters or more + All favourites + Empty category + Read later + Updates + New chapters of what you are reading is shown here + Search results + Related + New version: %s + Size: %s + Waiting for network… + Clear updates feed + Cleared + Rotate screen + Update + Feed update will start soon + Look for updates + Don\'t check + Enter password + Wrong password + Protect the app + Ask for password when starting Kotatsu + Repeat the password + Mismatching passwords + About + Version %s + Check for updates + Checking for updates… + Could not look for updates + No updates available + Right-to-left (←) + New category + Scale mode + Fit center + Fit to height + Fit to width + Keep at start + Black + Uses less power on AMOLED screens + Restart required + Backup and restore + Create data backup + Restore from backup + Restored + Preparing… + Create issue on GitHub + File not found + All data was restored + The data was restored, but there are errors + You can create backup of your history and favourites and restore it + Just now + Yesterday + Long ago + Group + Today + Tap to try again + The chosen configuration will be remembered for this manga + Silent + CAPTCHA required + Solve + Clear cookies + All cookies were removed + Checking for new chapters: %1$d of %2$d + Clear feed + Clear all update history permanently? + Check for new chapters + Reverse + Sign in + Sign in to view this content + Default: %s + …and %1$d more + Next + Enter a password to start the app with + Confirm + The password must be 4 characters or more Search only on %s - Remove all recent search queries permanently? - Other - Welcome - Backup saved - Some devices have different system behavior, which may break background tasks. - Read more - Queued - No active downloads - Download or read this missing chapter online. - The chapter is missing - Translate this app - Translation - Feedback - Topic on 4PDA + Remove all recent search queries permanently? + Other + Welcome + Backup saved + Some devices have different system behavior, which may break background tasks. + Read more + Queued + No active downloads + Download or read this missing chapter online. + The chapter is missing + Translate this app + Translation + Feedback + Topic on 4PDA Authorized - Logging in on %s is not supported - You will be logged out from all sources - Genres - Finished - Ongoing - Date format - Default - Exclude NSFW manga from history - You must enter a name - Numbered pages - Used sources - Available sources - Dynamic theme - Applies a theme created on the color scheme of your wallpaper + Logging in on %s is not supported + You will be logged out from all sources + Genres + Finished + Ongoing + Date format + Default + Exclude NSFW manga from history + You must enter a name + Numbered pages + Used sources + Available sources + Dynamic theme + Applies a theme created on the color scheme of your wallpaper Importing manga: %1$d of %2$d Screenshot policy Allow @@ -287,4 +284,23 @@ Enter your email to continue Hide New manga sources are available + Check for new chapters and notify about it + You will receive notifications about updates of manga you are reading + You will not receive notifications but new chapters will be highlighted in the lists + Enable notifications + Name + Edit + Edit category + No favourite categories + Add bookmark + Remove bookmark + Bookmarks + Bookmark removed + Bookmark added + Undo + Removed from history + DNS over HTTPS + Default mode + Autodetect reader mode + Automatically detect if manga is webtoon \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2333f5d68..7f96a0bef 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -22,8 +22,9 @@ - @@ -39,6 +40,7 @@ diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 12ccac10f..f6addbe8f 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -71,7 +71,8 @@ @style/Widget.Kotatsu.RecyclerView @style/Widget.Kotatsu.ListItemTextView - + + @style/TextAppearance.Kotatsu.Menu ?attr/textAppearanceBodyLarge @style/TextAppearance.Kotatsu.Preference.Secondary @@ -80,6 +81,8 @@