Compare commits

..

25 Commits
v9.4 ... devel

Author SHA1 Message Date
Koitharu
34f6e5232b Update readme 2025-11-04 10:40:44 +02:00
Koitharu
f205c1b3dc Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2025-11-04 10:35:35 +02:00
Milo Ivir
4b2a487c37 Translated using Weblate (Croatian)
Currently translated at 99.8% (895 of 896 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
MuhamadSyabitHidayattulloh
726ac21974 Translated using Weblate (Indonesian)
Currently translated at 99.8% (895 of 896 strings)

Co-authored-by: MuhamadSyabitHidayattulloh <tebepc@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
Robert Broketa
6b35216949 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (896 of 896 strings)

Co-authored-by: Robert Broketa <robert@broketa.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
João Augusto Casagrande
22cae62f17 Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (895 of 896 strings)

Co-authored-by: João Augusto Casagrande <joao.augusto1809@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
Oğuz Ersen
4733caf2e6 Translated using Weblate (Turkish)
Currently translated at 100.0% (896 of 896 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
Максим Горпиніч
d49103de1f Translated using Weblate (Ukrainian)
Currently translated at 100.0% (896 of 896 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (894 of 894 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (893 of 893 strings)

Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2025-11-04 10:34:14 +02:00
Koitharu
414bab7ce3 Update readme 2025-11-04 10:34:03 +02:00
Koitharu
64c1873eb5 Merge branch 'master' into devel 2025-11-04 09:49:07 +02:00
Koitharu
06a0b5829b Fix crashes
(cherry picked from commit 1d32f53bdd)
2025-11-03 20:27:53 +02:00
Koitharu
0ce2870c8b Fix chapters list not accessible
(cherry picked from commit 5701862661)
2025-11-03 20:27:24 +02:00
Koitharu
f59027666b Fix loading empty manga
(cherry picked from commit 5590ab7c8a)
2025-11-03 20:27:18 +02:00
Nathan Bapin
8513bc6daf Fix forget page when the screen is rotated (#1674)
(cherry picked from commit e2fcfcc7a8)
2025-11-03 20:27:10 +02:00
Koitharu
cceaefc896 Avoid memory leak in ExceptionResolver
(cherry picked from commit 7a3b2a9bb4)
2025-11-03 20:27:05 +02:00
Koitharu
1d32f53bdd Fix crashes 2025-11-03 20:26:15 +02:00
Koitharu
0e98dd8695 Refactor SearchMenuProvider 2025-11-03 20:23:43 +02:00
MuhamadSyabitHidayattulloh
119b7c2ac7 Add filtering options for pinned sources and empty results in search menu 2025-11-02 15:28:24 +02:00
Koitharu
5701862661 Fix chapters list not accessible 2025-11-02 15:26:56 +02:00
Koitharu
5590ab7c8a Fix loading empty manga 2025-11-02 15:12:32 +02:00
Koitharu
9fde0106be Fix code formatting 2025-11-02 11:05:14 +02:00
skepsun
e73f077dc5 remove unnecessary summary 2025-11-02 10:57:46 +02:00
skepsun
c37458d43a Add foldable device support (auto two-page) 2025-11-02 10:57:46 +02:00
Nathan Bapin
e2fcfcc7a8 Fix forget page when the screen is rotated (#1674) 2025-11-02 10:57:25 +02:00
Koitharu
7a3b2a9bb4 Avoid memory leak in ExceptionResolver 2025-11-02 10:56:23 +02:00
33 changed files with 2551 additions and 2283 deletions

View File

@@ -1,24 +1,16 @@
> [!IMPORTANT]
> In light of recent challenges — including threating actions from Kakao Entertainment Corp and upcoming Googles
> [new sideloading policy](https://f-droid.org/ru/2025/10/28/sideloading.html) — weve made the difficult decision to shut down Kotatsu and end its support. Were deeply grateful
> to everyone who contributed and to the amazing community that grew around this project.
---
<div align="center"> <div align="center">
<a href="https://kotatsu.app"> **[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/> online content sources.**
</a>
# [Kotatsu](https://kotatsu.app) ![Android 6.0](https://img.shields.io/badge/android-6.0+-brightgreen) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
![Downloads count](https://img.shields.io/github/downloads/KotatsuApp/Kotatsu/total?color=1976d2) ![Latest Stable version](https://img.shields.io/github/v/release/KotatsuApp/Kotatsu?color=2596be&label=latest) ![Android 6.0](https://img.shields.io/badge/android-6.0+-brightgreen) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
### Download
<div align="left">
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (Unstable, use at your own risk). Application has a built-in self-updating feature.
</div>
### Main Features ### Main Features
@@ -86,7 +78,8 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
</br> </br>
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines** **📌 Pull requests are welcome, if you want:
See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
### Certificate fingerprints ### Certificate fingerprints
@@ -104,7 +97,9 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
<div align="left"> <div align="left">
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
install instructions.
</div> </div>
@@ -112,6 +107,9 @@ You may copy, distribute and modify the software as long as you track changes/da
<div align="left"> <div align="left">
The developers of this application do not have any affiliation with the content available in the app and does not store or distribute any content. This application should be considered a web browser, all content that can be found using this application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website where the content is hosted. The developers of this application do not have any affiliation with the content available in the app and does not store
or distribute any content. This application should be considered a web browser, all content that can be found using this
application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website
where the content is hosted.
</div> </div>

View File

@@ -21,8 +21,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 23 minSdk = 23
targetSdk = 36 targetSdk = 36
versionCode = 1032 versionCode = 1033
versionName = '9.4' versionName = '9.4.1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -155,6 +155,9 @@ dependencies {
implementation libs.androidx.work.runtime implementation libs.androidx.work.runtime
implementation libs.guava implementation libs.guava
// Foldable/Window layout
implementation libs.androidx.window
implementation libs.androidx.room.runtime implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx implementation libs.androidx.room.ktx
ksp libs.androidx.room.compiler ksp libs.androidx.room.compiler

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.core.exceptions
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.model.Manga
class EmptyMangaException(
val reason: EmptyMangaReason?,
val manga: Manga,
cause: Throwable?
) : IllegalStateException(cause)

View File

@@ -8,13 +8,15 @@ import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import dagger.assisted.Assisted import androidx.lifecycle.Lifecycle
import dagger.assisted.AssistedFactory import androidx.lifecycle.LifecycleOwner
import dagger.assisted.AssistedInject import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.async
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
@@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.restartApplication import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -32,14 +35,15 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException import java.security.cert.CertPathValidatorException
import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class ExceptionResolver @AssistedInject constructor( class ExceptionResolver private constructor(
@Assisted private val host: Host, private val host: Host,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>, private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) { ) {
@@ -56,10 +60,11 @@ class ExceptionResolver @AssistedInject constructor(
} }
fun showErrorDetails(e: Throwable, url: String? = null) { fun showErrorDetails(e: Throwable, url: String? = null) {
host.router()?.showErrorDialog(e, url) host.router.showErrorDialog(e, url)
} }
suspend fun resolve(e: Throwable): Boolean = when (e) { suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
when (e) {
is CloudFlareProtectedException -> resolveCF(e) is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source) is AuthRequiredException -> resolveAuthException(e.source)
is SSLException, is SSLException,
@@ -71,7 +76,7 @@ class ExceptionResolver @AssistedInject constructor(
is InteractiveActionRequiredException -> resolveBrowserAction(e) is InteractiveActionRequiredException -> resolveBrowserAction(e)
is ProxyConfigException -> { is ProxyConfigException -> {
host.router()?.openProxySettings() host.router.openProxySettings()
false false
} }
@@ -80,6 +85,16 @@ class ExceptionResolver @AssistedInject constructor(
false false
} }
is EmptyMangaException -> {
when (e.reason) {
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
EmptyMangaReason.LOADING_ERROR -> Unit
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
else -> Unit
}
false
}
is UnsupportedSourceException -> { is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) } e.manga?.let { openAlternatives(it) }
false false
@@ -99,6 +114,7 @@ class ExceptionResolver @AssistedInject constructor(
else -> false else -> false
} }
}.await()
private suspend fun resolveBrowserAction( private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException e: InteractiveActionRequiredException
@@ -118,11 +134,11 @@ class ExceptionResolver @AssistedInject constructor(
} }
private fun openInBrowser(url: String) { private fun openInBrowser(url: String) {
host.router()?.openBrowser(url, null, null) host.router.openBrowser(url, null, null)
} }
private fun openAlternatives(manga: Manga) { private fun openAlternatives(manga: Manga) {
host.router()?.openAlternatives(manga) host.router.openAlternatives(manga)
} }
private fun handleActivityResult(tag: String, result: Boolean) { private fun handleActivityResult(tag: String, result: Boolean) {
@@ -130,7 +146,7 @@ class ExceptionResolver @AssistedInject constructor(
} }
private fun showSslErrorDialog() { private fun showSslErrorDialog() {
val ctx = host.getContext() ?: return val ctx = host.context ?: return
if (settings.isSSLBypassEnabled) { if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return return
@@ -147,27 +163,65 @@ class ExceptionResolver @AssistedInject constructor(
}.show() }.show()
} }
private inline fun Host.withContext(block: Context.() -> Unit) { class Factory @Inject constructor(
getContext()?.apply(block) private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
fun create(fragment: Fragment) = ExceptionResolver(
host = Host.FragmentHost(fragment),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
fun create(activity: FragmentActivity) = ExceptionResolver(
host = Host.ActivityHost(activity),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
} }
private fun Host.router(): AppRouter? = when (this) { private sealed interface Host : ActivityResultCaller, LifecycleOwner {
is FragmentActivity -> router
is Fragment -> router val context: Context?
else -> null
val router: AppRouter
val fragmentManager: FragmentManager
inline fun withContext(block: Context.() -> Unit) {
context?.apply(block)
} }
interface Host : ActivityResultCaller { class ActivityHost(val activity: FragmentActivity) : Host,
ActivityResultCaller by activity,
LifecycleOwner by activity {
fun getChildFragmentManager(): FragmentManager override val context: Context
get() = activity
fun getContext(): Context? override val router: AppRouter
get() = activity.router
override val fragmentManager: FragmentManager
get() = activity.supportFragmentManager
} }
@AssistedFactory class FragmentHost(val fragment: Fragment) : Host,
interface Factory { ActivityResultCaller by fragment {
fun create(host: Host): ExceptionResolver override val context: Context?
get() = fragment.context
override val router: AppRouter
get() = fragment.router
override val fragmentManager: FragmentManager
get() = fragment.childFragmentManager
override val lifecycle: Lifecycle
get() = fragment.viewLifecycleOwner.lifecycle
}
} }
companion object { companion object {
@@ -187,6 +241,12 @@ class ExceptionResolver @AssistedInject constructor(
is InteractiveActionRequiredException -> R.string._continue is InteractiveActionRequiredException -> R.string._continue
is EmptyMangaException -> when (e.reason) {
EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0
EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives
else -> 0
}
else -> 0 else -> 0
} }

View File

@@ -215,6 +215,12 @@ class AppRouter private constructor(
startActivity(browserIntent(contextOrNull() ?: return, url, source, title)) startActivity(browserIntent(contextOrNull() ?: return, url, source, title))
} }
fun openBrowser(manga: Manga) = openBrowser(
url = manga.publicUrl,
source = manga.source,
title = manga.title,
)
fun openColorFilterConfig(manga: Manga, page: MangaPage) { fun openColorFilterConfig(manga: Manga, page: MangaPage) {
startActivity( startActivity(
Intent(contextOrNull(), ColorFilterConfigActivity::class.java) Intent(contextOrNull(), ColorFilterConfigActivity::class.java)

View File

@@ -138,6 +138,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false) get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) } set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
var isReaderDoubleOnFoldable: Boolean
get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, value) }
@get:FloatRange(0.0, 1.0) @get:FloatRange(0.0, 1.0)
var readerDoublePagesSensitivity: Float var readerDoublePagesSensitivity: Float
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f) get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
@@ -681,7 +685,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages" const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity" const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2"
const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons" const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr" const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted" const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"

View File

@@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(), AppCompatActivity(),
ExceptionResolver.Host,
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener,
ScreenshotPolicyHelper.ContentContainer { ScreenshotPolicyHelper.ContentContainer {
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR) @Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
override fun setContentView(view: View?) = throw UnsupportedOperationException() override fun setContentView(view: View?) = throw UnsupportedOperationException()
override fun getContext() = this
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
protected fun setContentView(binding: B) { protected fun setContentView(binding: B) {
this.viewBinding = binding this.viewBinding = binding
super.setContentView(binding.root) super.setContentView(binding.root)

View File

@@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
abstract class BaseFragment<B : ViewBinding> : abstract class BaseFragment<B : ViewBinding> :
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener,
Fragment(), Fragment() {
ExceptionResolver.Host {
var viewBinding: B? = null var viewBinding: B? = null
private set private set

View File

@@ -36,8 +36,7 @@ import com.google.android.material.R as materialR
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener,
RecyclerViewOwner, RecyclerViewOwner {
ExceptionResolver.Host {
protected lateinit var exceptionResolver: ExceptionResolver protected lateinit var exceptionResolver: ExceptionResolver
private set private set

View File

@@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(), abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
OnApplyWindowInsetsListener, OnApplyWindowInsetsListener {
ExceptionResolver.Host {
private var waitingForDismissAllowingStateLoss = false private var waitingForDismissAllowingStateLoss = false
private var isFitToContentsDisabled = false private var isFitToContentsDisabled = false

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
@@ -103,6 +104,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
is AccessDeniedException -> resources.getString(R.string.no_access_to_file) is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri) is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration) is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException, is SyncApiException,
is ContentUnavailableException -> message is ContentUnavailableException -> message
@@ -167,6 +169,8 @@ fun Throwable.getCauseUrl(): String? = when (this) {
is CloudFlareProtectedException -> url is CloudFlareProtectedException -> url
is InteractiveActionRequiredException -> url is InteractiveActionRequiredException -> url
is HttpStatusException -> url is HttpStatusException -> url
is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() }
is HttpException -> (response.delegate as? Response)?.request?.url?.toString() is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
else -> null else -> null
} }

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.data.filterChapters import org.koitharu.kotatsu.reader.data.filterChapters
@@ -50,6 +51,9 @@ data class MangaDetails(
.ifNullOrEmpty { localManga?.manga?.coverUrl } .ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty() ?.nullIfEmpty()
val isRestricted: Boolean
get() = manga.state == MangaState.RESTRICTED
private val mergedManga by lazy { private val mergedManga by lazy {
if (localManga == null) { if (localManga == null) {
// fast path // fast path

View File

@@ -100,7 +100,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
override fun onStateChanged(sheet: View, newState: Int) { override fun onStateChanged(sheet: View, newState: Int) {
val binding = viewBinding ?: return val binding = viewBinding ?: return
binding.layoutTouchBlock.isTouchEventsAllowed = newState != STATE_COLLAPSED binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) { if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
return return
} }

View File

@@ -133,9 +133,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
layoutBody.updatePadding(top = layoutBody.paddingBottom) layoutBody.updatePadding(top = layoutBody.paddingBottom)
scrollView.scrollIndicators = 0 scrollView.scrollIndicators = 0
buttonDone.isVisible = false buttonDone.isVisible = false
this.root.updateLayoutParams { this.root.layoutParams?.height = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
}
buttonSave.updateLayoutParams<LinearLayout.LayoutParams> { buttonSave.updateLayoutParams<LinearLayout.LayoutParams> {
weight = 0f weight = 0f
width = LinearLayout.LayoutParams.WRAP_CONTENT width = LinearLayout.LayoutParams.WRAP_CONTENT

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui
import android.app.assist.AssistContent import android.app.assist.AssistContent
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.Gravity import android.view.Gravity
import android.view.KeyEvent import android.view.KeyEvent
@@ -24,6 +25,8 @@ import androidx.transition.Fade
import androidx.transition.Slide import androidx.transition.Slide
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import androidx.transition.TransitionSet import androidx.transition.TransitionSet
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -31,7 +34,9 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -110,6 +115,9 @@ class ReaderActivity :
private lateinit var readerManager: ReaderManager private lateinit var readerManager: ReaderManager
private val hideUiRunnable = Runnable { setUiIsVisible(false) } private val hideUiRunnable = Runnable { setUiIsVisible(false) }
// Tracks whether the foldable device is in an unfolded state (half-opened or flat)
private var isFoldUnfolded: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater)) setContentView(ActivityReaderBinding.inflate(layoutInflater))
@@ -187,6 +195,11 @@ class ReaderActivity :
viewBinding.zoomControl.isVisible = it viewBinding.zoomControl.isVisible = it
} }
addMenuProvider(ReaderMenuProvider(viewModel)) addMenuProvider(ReaderMenuProvider(viewModel))
observeWindowLayout()
// Apply initial double-mode considering foldable setting
applyDoubleModeAuto()
} }
override fun getParentActivityIntent(): Intent? { override fun getParentActivityIntent(): Intent? {
@@ -341,7 +354,17 @@ class ReaderActivity :
} }
override fun onDoubleModeChanged(isEnabled: Boolean) { override fun onDoubleModeChanged(isEnabled: Boolean) {
readerManager.setDoubleReaderMode(isEnabled) // Combine manual toggle with foldable auto setting
applyDoubleModeAuto(isEnabled)
}
private fun applyDoubleModeAuto(manualEnabled: Boolean? = null) {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
// Auto double-page on foldable when device is unfolded (half-opened or flat)
val autoFoldable = settings.isReaderDoubleOnFoldable && isFoldUnfolded
val manualLandscape = (manualEnabled ?: settings.isReaderDoubleOnLandscape) && isLandscape
val autoEnabled = autoFoldable || manualLandscape
readerManager.setDoubleReaderMode(autoEnabled)
} }
private fun setKeepScreenOn(isKeep: Boolean) { private fun setKeepScreenOn(isKeep: Boolean) {
@@ -521,6 +544,24 @@ class ReaderActivity :
} }
} }
// Observe foldable window layout to auto-enable double-page if configured
private fun observeWindowLayout() {
WindowInfoTracker.getOrCreate(this)
.windowLayoutInfo(this)
.onEach { info ->
val fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
val unfolded = when (fold?.state) {
FoldingFeature.State.HALF_OPENED, FoldingFeature.State.FLAT -> true
else -> false
}
if (unfolded != isFoldUnfolded) {
isFoldUnfolded = unfolded
applyDoubleModeAuto()
}
}
.launchIn(lifecycleScope)
}
private fun askForIncognitoMode() { private fun askForIncognitoMode() {
buildAlertDialog(this, isCentered = true) { buildAlertDialog(this, isCentered = true) {
var dontAskAgain = false var dontAskAgain = false

View File

@@ -49,7 +49,7 @@ class ReaderManager(
fun setDoubleReaderMode(isEnabled: Boolean) { fun setDoubleReaderMode(isEnabled: Boolean) {
val mode = currentMode val mode = currentMode
val prevReader = currentReader?.javaClass val prevReader = currentReader?.javaClass
invalidateTypesMap(isEnabled && isLandscape()) invalidateTypesMap(isEnabled)
val newReader = modeMap[mode] val newReader = modeMap[mode]
if (mode != null && newReader != prevReader) { if (mode != null && newReader != prevReader) {
replace(mode) replace(mode)

View File

@@ -29,6 +29,7 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.nav.MangaIntent
import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.nav.ReaderIntent
@@ -47,6 +48,7 @@ import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase
@@ -405,9 +407,11 @@ class ReaderViewModel @Inject constructor(
private fun loadImpl() { private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) { loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) {
var exception: Exception? = null var exception: Exception? = null
var loadedDetails: MangaDetails? = null
try { try {
detailsLoadUseCase(intent, force = false) detailsLoadUseCase(intent, force = false)
.collect { details -> .collect { details ->
loadedDetails = details
if (mangaDetails.value == null) { if (mangaDetails.value == null) {
mangaDetails.value = details mangaDetails.value = details
} }
@@ -452,9 +456,28 @@ class ReaderViewModel @Inject constructor(
exception = e.mergeWith(exception) exception = e.mergeWith(exception)
} }
if (readingState.value == null) { if (readingState.value == null) {
onLoadingError.call( val loadedManga = loadedDetails // for smart cast
exception ?: IllegalStateException("Unable to load manga. This should never happen. Please report"), if (loadedManga != null) {
mangaDetails.value = loadedManga.filterChapters(selectedBranch.value)
}
val loadingError = when {
exception != null -> exception
loadedManga == null || !loadedManga.isLoaded -> null
loadedManga.isRestricted -> EmptyMangaException(
EmptyMangaReason.RESTRICTED,
loadedManga.toManga(),
null,
) )
loadedManga.allChapters.isEmpty() -> EmptyMangaException(
EmptyMangaReason.NO_CHAPTERS,
loadedManga.toManga(),
null,
)
else -> null
} ?: IllegalStateException("Unable to load manga. This should never happen. Please report")
onLoadingError.call(loadingError)
} else exception?.let { e -> } else exception?.let { e ->
// manga has been loaded but error occurred // manga has been loaded but error occurred
errorEvent.call(e) errorEvent.call(e)

View File

@@ -91,6 +91,8 @@ class ReaderConfigSheet :
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
binding.switchDoubleFoldable.isChecked = settings.isReaderDoubleOnFoldable
binding.switchDoubleFoldable.isEnabled = binding.switchDoubleReader.isEnabled
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f) binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context)) binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
binding.adjustSensitivitySlider(withAnimation = false) binding.adjustSensitivitySlider(withAnimation = false)
@@ -104,6 +106,7 @@ class ReaderConfigSheet :
binding.buttonScrollTimer.setOnClickListener(this) binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this) binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this) binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.switchDoubleFoldable.setOnCheckedChangeListener(this)
binding.sliderDoubleSensitivity.addOnChangeListener(this) binding.sliderDoubleSensitivity.addOnChangeListener(this)
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) { viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
@@ -182,6 +185,12 @@ class ReaderConfigSheet :
viewBinding?.adjustSensitivitySlider(withAnimation = true) viewBinding?.adjustSensitivitySlider(withAnimation = true)
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked) findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
} }
R.id.switch_double_foldable -> {
settings.isReaderDoubleOnFoldable = isChecked
// Re-evaluate double-page considering foldable state and current manual toggle
findParentCallback(Callback::class.java)?.onDoubleModeChanged(settings.isReaderDoubleOnLandscape)
}
} }
} }
@@ -206,6 +215,7 @@ class ReaderConfigSheet :
} }
viewBinding?.run { viewBinding?.run {
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled
adjustSensitivitySlider(withAnimation = true) adjustSensitivitySlider(withAnimation = true)
} }
if (newMode == mode) { if (newMode == mode) {
@@ -242,12 +252,18 @@ class ReaderConfigSheet :
} }
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) { private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
val isSliderVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked val isSubOptionsVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
if (isSliderVisible != sliderDoubleSensitivity.isVisible && withAnimation) { val needTransition = withAnimation && (
(isSubOptionsVisible != sliderDoubleSensitivity.isVisible) ||
(isSubOptionsVisible != textDoubleSensitivity.isVisible) ||
(isSubOptionsVisible != switchDoubleFoldable.isVisible)
)
if (needTransition) {
TransitionManager.beginDelayedTransition(layoutMain) TransitionManager.beginDelayedTransition(layoutMain)
} }
sliderDoubleSensitivity.isVisible = isSliderVisible sliderDoubleSensitivity.isVisible = isSubOptionsVisible
textDoubleSensitivity.isVisible = isSliderVisible textDoubleSensitivity.isVisible = isSubOptionsVisible
switchDoubleFoldable.isVisible = isSubOptionsVisible
} }
interface Callback { interface Callback {

View File

@@ -25,11 +25,26 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
readerAdapter = onCreateAdapter() readerAdapter = onCreateAdapter()
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) { // Determine which state to use for restoring position:
onPagesChanged(it.pages, viewModel.getCurrentState()) // - content.state: explicitly set state (e.g., after mode switch or chapter change)
} else { // - getCurrentState(): current reading position saved in SavedStateHandle
onPagesChanged(it.pages, it.state) val currentState = viewModel.getCurrentState()
val pendingState = when {
// If content.state is null and we have pages, use getCurrentState
it.state == null
&& it.pages.isNotEmpty()
&& readerAdapter?.hasItems != true -> currentState
// use currentState only if it matches the current pages (to avoid the error message)
readerAdapter?.hasItems != true
&& it.state != currentState
&& currentState != null
&& it.pages.any { page -> page.chapterId == currentState.chapterId } -> currentState
// Otherwise, use content.state (normal flow, mode switch, chapter change)
else -> it.state
} }
onPagesChanged(it.pages, pendingState)
} }
} }

View File

@@ -94,7 +94,7 @@ class SearchActivity :
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
supportActionBar?.setSubtitle(R.string.search_results) supportActionBar?.setSubtitle(R.string.search_results)
addMenuProvider(SearchKindMenuProvider(this, viewModel.query, viewModel.kind)) addMenuProvider(SearchMenuProvider(this, viewModel))
viewModel.list.observe(this, adapter) viewModel.list.observe(this, adapter)
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))

View File

@@ -9,10 +9,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.domain.SearchKind
class SearchKindMenuProvider( class SearchMenuProvider(
private val activity: SearchActivity, private val activity: SearchActivity,
private val query: String, private val viewModel: SearchViewModel,
private val kind: SearchKind
) : MenuProvider { ) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -22,7 +21,7 @@ class SearchKindMenuProvider(
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu) super.onPrepareMenu(menu)
menu.findItem( menu.findItem(
when (kind) { when (viewModel.kind) {
SearchKind.SIMPLE -> R.id.action_kind_simple SearchKind.SIMPLE -> R.id.action_kind_simple
SearchKind.TITLE -> R.id.action_kind_title SearchKind.TITLE -> R.id.action_kind_title
SearchKind.AUTHOR -> R.id.action_kind_author SearchKind.AUTHOR -> R.id.action_kind_author
@@ -32,6 +31,20 @@ class SearchKindMenuProvider(
} }
override fun onMenuItemSelected(menuItem: MenuItem): Boolean { override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_filter_pinned_only -> {
menuItem.isChecked = !menuItem.isChecked
viewModel.setPinnedOnly(menuItem.isChecked)
return true
}
R.id.action_filter_hide_empty -> {
menuItem.isChecked = !menuItem.isChecked
viewModel.setHideEmpty(menuItem.isChecked)
return true
}
}
val newKind = when (menuItem.itemId) { val newKind = when (menuItem.itemId) {
R.id.action_kind_simple -> SearchKind.SIMPLE R.id.action_kind_simple -> SearchKind.SIMPLE
R.id.action_kind_title -> SearchKind.TITLE R.id.action_kind_title -> SearchKind.TITLE
@@ -39,9 +52,9 @@ class SearchKindMenuProvider(
R.id.action_kind_tag -> SearchKind.TAG R.id.action_kind_tag -> SearchKind.TAG
else -> return false else -> return false
} }
if (newKind != kind) { if (newKind != viewModel.kind) {
activity.router.openSearch( activity.router.openSearch(
query = query, query = viewModel.query,
kind = newKind, kind = newKind,
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {

View File

@@ -62,6 +62,8 @@ class SearchViewModel @Inject constructor(
val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE
private var includeDisabledSources = MutableStateFlow(false) private var includeDisabledSources = MutableStateFlow(false)
private var pinnedOnly = MutableStateFlow(false)
private var hideEmpty = MutableStateFlow(false)
private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList()) private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList())
private var searchJob: Job? = null private var searchJob: Job? = null
@@ -70,9 +72,15 @@ class SearchViewModel @Inject constructor(
results, results,
isLoading.dropWhile { !it }, isLoading.dropWhile { !it },
includeDisabledSources, includeDisabledSources,
) { list, loading, includeDisabled -> hideEmpty,
) { list, loading, includeDisabled, hideEmptyVal ->
val filteredList = if (hideEmptyVal) {
list.filter { it.list.isNotEmpty() }
} else {
list
}
when { when {
list.isEmpty() -> listOf( filteredList.isEmpty() -> listOf(
when { when {
loading -> LoadingState loading -> LoadingState
else -> EmptyState( else -> EmptyState(
@@ -84,9 +92,9 @@ class SearchViewModel @Inject constructor(
}, },
) )
loading -> list + LoadingFooter() loading -> filteredList + LoadingFooter()
includeDisabled -> list includeDisabled -> filteredList
else -> list + ButtonFooter(R.string.search_disabled_sources) else -> filteredList + ButtonFooter(R.string.search_disabled_sources)
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
@@ -114,6 +122,17 @@ class SearchViewModel @Inject constructor(
doSearch() doSearch()
} }
fun setPinnedOnly(value: Boolean) {
if (pinnedOnly.value != value) {
pinnedOnly.value = value
retry()
}
}
fun setHideEmpty(value: Boolean) {
hideEmpty.value = value
}
fun continueSearch() { fun continueSearch() {
if (includeDisabledSources.value) { if (includeDisabledSources.value) {
return return
@@ -122,8 +141,12 @@ class SearchViewModel @Inject constructor(
searchJob = launchLoadingJob(Dispatchers.Default) { searchJob = launchLoadingJob(Dispatchers.Default) {
includeDisabledSources.value = true includeDisabledSources.value = true
prevJob?.join() prevJob?.join()
val sources = sourcesRepository.getDisabledSources() val sources = if (pinnedOnly.value) {
emptyList()
} else {
sourcesRepository.getDisabledSources()
.sortedByDescending { it.priority() } .sortedByDescending { it.priority() }
}
val semaphore = Semaphore(MAX_PARALLELISM) val semaphore = Semaphore(MAX_PARALLELISM)
sources.map { source -> sources.map { source ->
launch { launch {
@@ -142,7 +165,11 @@ class SearchViewModel @Inject constructor(
appendResult(searchHistory()) appendResult(searchHistory())
appendResult(searchFavorites()) appendResult(searchFavorites())
appendResult(searchLocal()) appendResult(searchLocal())
val sources = sourcesRepository.getEnabledSources() val sources = if (pinnedOnly.value) {
sourcesRepository.getPinnedSources().toList()
} else {
sourcesRepository.getEnabledSources()
}
val semaphore = Semaphore(MAX_PARALLELISM) val semaphore = Semaphore(MAX_PARALLELISM)
sources.map { source -> sources.map { source ->
launch { launch {

View File

@@ -130,6 +130,21 @@
android:textColor="?colorOnSurfaceVariant" android:textColor="?colorOnSurfaceVariant"
app:drawableStartCompat="@drawable/ic_split_horizontal" /> app:drawableStartCompat="@drawable/ic_split_horizontal" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_double_foldable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:minHeight="?android:listPreferredItemHeightSmall"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/auto_double_foldable"
android:textAppearance="@style/TextAppearance.Kotatsu.GridTitle"
android:textColor="?colorOnSurfaceVariant"
android:visibility="gone"
tools:visibility="visible" />
<TextView <TextView
android:id="@+id/text_double_sensitivity" android:id="@+id/text_double_sensitivity"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -33,6 +33,20 @@
android:title="@string/genre" /> android:title="@string/genre" />
</group> </group>
<group android:id="@+id/group_search_filters">
<item
android:id="@+id/action_filter_pinned_only"
android:checkable="true"
android:title="@string/pinned_sources_only" />
<item
android:id="@+id/action_filter_hide_empty"
android:checkable="true"
android:title="@string/hide_empty_sources" />
</group>
</menu> </menu>
</item> </item>

View File

@@ -879,4 +879,6 @@
<string name="download_default_directory">Standardni direktorij za preuzimanje manga</string> <string name="download_default_directory">Standardni direktorij za preuzimanje manga</string>
<string name="private_app_directory_warning">Ako deinstaliraš aplikaciju, ovaj će se direktorij izbrisati sa svim podacima</string> <string name="private_app_directory_warning">Ako deinstaliraš aplikaciju, ovaj će se direktorij izbrisati sa svim podacima</string>
<string name="available_pattern">%1$s dostupno</string> <string name="available_pattern">%1$s dostupno</string>
<string name="pinned_sources_only">Samo prikvačeni izvori</string>
<string name="hide_empty_sources">Sakrij prazne izvore</string>
</resources> </resources>

View File

@@ -883,4 +883,6 @@
<string name="download_default_directory">Direktori default untuk mengunduh manga</string> <string name="download_default_directory">Direktori default untuk mengunduh manga</string>
<string name="private_app_directory_warning">Direktori ini beserta semua datanya akan dihapus jika Anda menghapus aplikasi</string> <string name="private_app_directory_warning">Direktori ini beserta semua datanya akan dihapus jika Anda menghapus aplikasi</string>
<string name="available_pattern">%1$s tersedia</string> <string name="available_pattern">%1$s tersedia</string>
<string name="pinned_sources_only">Hanya sumber yang disematkan</string>
<string name="hide_empty_sources">Sembunyikan sumber kosong</string>
</resources> </resources>

View File

@@ -881,4 +881,7 @@
<string name="download_default_directory">Diretório padrão onde baixar os mangás</string> <string name="download_default_directory">Diretório padrão onde baixar os mangás</string>
<string name="private_app_directory_warning">Esse diretório e todo seu conteúdo será deletado se desinstalar a aplicação</string> <string name="private_app_directory_warning">Esse diretório e todo seu conteúdo será deletado se desinstalar a aplicação</string>
<string name="available_pattern">%1$s disponível</string> <string name="available_pattern">%1$s disponível</string>
<string name="hide_empty_sources">Esconder fontes vazias</string>
<string name="pinned_sources_only">Apenas fontes fixadas</string>
<string name="auto_double_foldable">Duas Páginas Automático em Tela Dobrável</string>
</resources> </resources>

View File

@@ -884,4 +884,7 @@
<string name="private_app_directory_warning">Uygulamayı kaldırırsanız, bu dizin ve içindeki tüm veriler silinecektir</string> <string name="private_app_directory_warning">Uygulamayı kaldırırsanız, bu dizin ve içindeki tüm veriler silinecektir</string>
<string name="available_pattern">%1$s var</string> <string name="available_pattern">%1$s var</string>
<string name="frequency_every_6_hours">6 saatte bir</string> <string name="frequency_every_6_hours">6 saatte bir</string>
<string name="pinned_sources_only">Yalnızca sabitlenen kaynaklar</string>
<string name="hide_empty_sources">Boş kaynakları gizle</string>
<string name="auto_double_foldable">Otomatik İki Sayfa Katlanabilir</string>
</resources> </resources>

View File

@@ -880,4 +880,11 @@
<string name="data_removal">Вилучення даних</string> <string name="data_removal">Вилучення даних</string>
<string name="privacy">Конфіденційність</string> <string name="privacy">Конфіденційність</string>
<string name="source_broken_warning">Це джерело манги позначено як непрацююче. Деякі функції можуть не працювати</string> <string name="source_broken_warning">Це джерело манги позначено як непрацююче. Деякі функції можуть не працювати</string>
<string name="frequency_every_6_hours">Кожні 6 годин</string>
<string name="download_default_directory">Каталог за замовчуванням для завантаження манги</string>
<string name="private_app_directory_warning">Цей каталог з усіма даними буде видалено, якщо ви видалите програму</string>
<string name="available_pattern">Доступно %1$s</string>
<string name="auto_double_foldable">Автоматичне двосторінкове розміщення на складному</string>
<string name="pinned_sources_only">Тільки закріплені джерела</string>
<string name="hide_empty_sources">Приховати порожні джерела</string>
</resources> </resources>

View File

@@ -866,4 +866,5 @@
<string name="pull_bottom_no_next">到底了</string> <string name="pull_bottom_no_next">到底了</string>
<string name="enable_pull_gesture_title">启用推拉手势</string> <string name="enable_pull_gesture_title">启用推拉手势</string>
<string name="enable_pull_gesture_summary">条漫模式下使用推拉手势切换章节</string> <string name="enable_pull_gesture_summary">条漫模式下使用推拉手势切换章节</string>
<string name="auto_double_foldable">折叠设备自动双页</string>
</resources> </resources>

View File

@@ -661,4 +661,5 @@
<string name="download_new_chapters">下載新的漫畫章節</string> <string name="download_new_chapters">下載新的漫畫章節</string>
<string name="enable_all_sources">啟用所有漫畫來源</string> <string name="enable_all_sources">啟用所有漫畫來源</string>
<string name="all_sources_enabled">所有漫畫來源已啟用</string> <string name="all_sources_enabled">所有漫畫來源已啟用</string>
<string name="auto_double_foldable">摺疊設備自動雙頁</string>
</resources> </resources>

View File

@@ -210,6 +210,8 @@
<string name="disabled">Disabled</string> <string name="disabled">Disabled</string>
<string name="reset_filter">Reset filter</string> <string name="reset_filter">Reset filter</string>
<string name="enter_name">Enter name</string> <string name="enter_name">Enter name</string>
<string name="pinned_sources_only">Pinned sources only</string>
<string name="hide_empty_sources">Hide empty sources</string>
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string> <string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
<string name="never">Never</string> <string name="never">Never</string>
<string name="only_using_wifi">Only on Wi-Fi</string> <string name="only_using_wifi">Only on Wi-Fi</string>
@@ -579,6 +581,7 @@
<string name="none">None</string> <string name="none">None</string>
<string name="config_reset_confirm">Reset settings to default values? This action cannot be undone.</string> <string name="config_reset_confirm">Reset settings to default values? This action cannot be undone.</string>
<string name="use_two_pages_landscape">Use two pages layout on landscape orientation (beta)</string> <string name="use_two_pages_landscape">Use two pages layout on landscape orientation (beta)</string>
<string name="auto_double_foldable">Auto Two-Page On Foldable</string>
<string name="two_page_scroll_sensitivity">Two-Page Scroll Sensitivity</string> <string name="two_page_scroll_sensitivity">Two-Page Scroll Sensitivity</string>
<string name="default_webtoon_zoom_out">Default webtoon zoom out</string> <string name="default_webtoon_zoom_out">Default webtoon zoom out</string>
<string name="fullscreen_mode">Fullscreen mode</string> <string name="fullscreen_mode">Fullscreen mode</string>

View File

@@ -49,6 +49,7 @@ viewpager2 = "1.1.0"
webkit = "1.14.0" webkit = "1.14.0"
workRuntime = "2.10.5" workRuntime = "2.10.5"
workinspector = "1.2" workinspector = "1.2"
window = "1.3.0"
[libraries] [libraries]
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" } acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }
@@ -115,6 +116,7 @@ okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp
okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
ssiv = { module = "com.github.KotatsuApp:subsampling-scale-image-view", version.ref = "ssiv" } ssiv = { module = "com.github.KotatsuApp:subsampling-scale-image-view", version.ref = "ssiv" }
workinspector = { module = "com.github.Koitharu:WorkInspector", version.ref = "workinspector" } workinspector = { module = "com.github.Koitharu:WorkInspector", version.ref = "workinspector" }
androidx-window = { module = "androidx.window:window", version.ref = "window" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "gradle" } android-application = { id = "com.android.application", version.ref = "gradle" }