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">
<a href="https://kotatsu.app">
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
</a>
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in
online content sources.**
# [Kotatsu](https://kotatsu.app)
**[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>
![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)
### Main Features
@@ -86,7 +78,8 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
</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
@@ -104,7 +97,9 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
<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>
@@ -112,6 +107,9 @@ You may copy, distribute and modify the software as long as you track changes/da
<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>

View File

@@ -21,8 +21,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 23
targetSdk = 36
versionCode = 1032
versionName = '9.4'
versionCode = 1033
versionName = '9.4.1'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -155,6 +155,9 @@ dependencies {
implementation libs.androidx.work.runtime
implementation libs.guava
// Foldable/Window layout
implementation libs.androidx.window
implementation libs.androidx.room.runtime
implementation libs.androidx.room.ktx
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.FragmentActivity
import androidx.fragment.app.FragmentManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.async
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
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.util.ext.isHttpUrl
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.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
@@ -32,164 +35,221 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.inject.Inject
import javax.inject.Provider
import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver @AssistedInject constructor(
@Assisted private val host: Host,
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
class ExceptionResolver private constructor(
private val host: Host,
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
handleActivityResult(BrowserActivity.TAG, true)
}
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
handleActivityResult(SourceAuthActivity.TAG, it)
}
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
handleActivityResult(CloudFlareActivity.TAG, it)
}
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
handleActivityResult(BrowserActivity.TAG, true)
}
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
handleActivityResult(SourceAuthActivity.TAG, it)
}
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
handleActivityResult(CloudFlareActivity.TAG, it)
}
fun showErrorDetails(e: Throwable, url: String? = null) {
host.router()?.showErrorDialog(e, url)
}
fun showErrorDetails(e: Throwable, url: String? = null) {
host.router.showErrorDialog(e, url)
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source)
is SSLException,
is CertPathValidatorException -> {
showSslErrorDialog()
false
}
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
when (e) {
is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source)
is SSLException,
is CertPathValidatorException -> {
showSslErrorDialog()
false
}
is InteractiveActionRequiredException -> resolveBrowserAction(e)
is InteractiveActionRequiredException -> resolveBrowserAction(e)
is ProxyConfigException -> {
host.router()?.openProxySettings()
false
}
is ProxyConfigException -> {
host.router.openProxySettings()
false
}
is NotFoundException -> {
openInBrowser(e.url)
false
}
is NotFoundException -> {
openInBrowser(e.url)
false
}
is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) }
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 ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) {
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
}
false
}
}
is UnsupportedSourceException -> {
e.manga?.let { openAlternatives(it) }
false
}
else -> false
}
is ScrobblerAuthRequiredException -> {
val authHelper = scrobblerAuthHelperProvider.get()
if (authHelper.isAuthorized(e.scrobbler)) {
true
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
}
false
}
}
private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException
): Boolean = suspendCoroutine { cont ->
continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e)
}
else -> false
}
}.await()
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(e)
}
private suspend fun resolveBrowserAction(
e: InteractiveActionRequiredException
): Boolean = suspendCoroutine { cont ->
continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
continuations[SourceAuthActivity.TAG] = cont
sourceAuthContract.launch(source)
}
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[CloudFlareActivity.TAG] = cont
cloudflareContract.launch(e)
}
private fun openInBrowser(url: String) {
host.router()?.openBrowser(url, null, null)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
continuations[SourceAuthActivity.TAG] = cont
sourceAuthContract.launch(source)
}
private fun openAlternatives(manga: Manga) {
host.router()?.openAlternatives(manga)
}
private fun openInBrowser(url: String) {
host.router.openBrowser(url, null, null)
}
private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result)
}
private fun openAlternatives(manga: Manga) {
host.router.openAlternatives(manga)
}
private fun showSslErrorDialog() {
val ctx = host.getContext() ?: return
if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
}
buildAlertDialog(ctx) {
setTitle(R.string.ignore_ssl_errors)
setMessage(R.string.ignore_ssl_errors_summary)
setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
ctx.restartApplication()
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun handleActivityResult(tag: String, result: Boolean) {
continuations.remove(tag)?.resume(result)
}
private inline fun Host.withContext(block: Context.() -> Unit) {
getContext()?.apply(block)
}
private fun showSslErrorDialog() {
val ctx = host.context ?: return
if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
}
buildAlertDialog(ctx) {
setTitle(R.string.ignore_ssl_errors)
setMessage(R.string.ignore_ssl_errors_summary)
setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
ctx.restartApplication()
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private fun Host.router(): AppRouter? = when (this) {
is FragmentActivity -> router
is Fragment -> router
else -> null
}
class Factory @Inject constructor(
private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) {
interface Host : ActivityResultCaller {
fun create(fragment: Fragment) = ExceptionResolver(
host = Host.FragmentHost(fragment),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
fun getChildFragmentManager(): FragmentManager
fun create(activity: FragmentActivity) = ExceptionResolver(
host = Host.ActivityHost(activity),
settings = settings,
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
)
}
fun getContext(): Context?
}
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
@AssistedFactory
interface Factory {
val context: Context?
fun create(host: Host): ExceptionResolver
}
val router: AppRouter
companion object {
val fragmentManager: FragmentManager
@StringRes
fun getResolveStringId(e: Throwable) = when (e) {
is CloudFlareProtectedException -> R.string.captcha_solve
is ScrobblerAuthRequiredException,
is AuthRequiredException -> R.string.sign_in
inline fun withContext(block: Context.() -> Unit) {
context?.apply(block)
}
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
is SSLException,
is CertPathValidatorException -> R.string.fix
class ActivityHost(val activity: FragmentActivity) : Host,
ActivityResultCaller by activity,
LifecycleOwner by activity {
is ProxyConfigException -> R.string.settings
override val context: Context
get() = activity
is InteractiveActionRequiredException -> R.string._continue
override val router: AppRouter
get() = activity.router
else -> 0
}
override val fragmentManager: FragmentManager
get() = activity.supportFragmentManager
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
}
class FragmentHost(val fragment: Fragment) : Host,
ActivityResultCaller by fragment {
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 {
@StringRes
fun getResolveStringId(e: Throwable) = when (e) {
is CloudFlareProtectedException -> R.string.captcha_solve
is ScrobblerAuthRequiredException,
is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
is SSLException,
is CertPathValidatorException -> R.string.fix
is ProxyConfigException -> R.string.settings
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
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -138,6 +138,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
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)
var readerDoublePagesSensitivity: Float
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_LOCAL_STORAGE = "local_storage"
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_CONTROL_LTR = "reader_taps_ltr"
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> :
AppCompatActivity(),
ExceptionResolver.Host,
OnApplyWindowInsetsListener,
ScreenshotPolicyHelper.ContentContainer {
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
override fun setContentView(view: View?) = throw UnsupportedOperationException()
override fun getContext() = this
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
protected fun setContentView(binding: B) {
this.viewBinding = binding
super.setContentView(binding.root)

View File

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

View File

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

View File

@@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import com.google.android.material.R as materialR
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
OnApplyWindowInsetsListener,
ExceptionResolver.Host {
OnApplyWindowInsetsListener {
private var waitingForDismissAllowingStateLoss = 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.CloudFlareProtectedException
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.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
@@ -62,216 +63,219 @@ private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
?: resources.getString(R.string.error_occurred)
?: resources.getString(R.string.error_occurred)
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
)
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId),
)
is AuthRequiredException -> resources.getString(R.string.auth_required)
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException,
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is AuthRequiredException -> resources.getString(R.string.auth_required)
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException,
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is TooManyRequestExceptions -> {
val delay = getRetryDelay()
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
resources.formatDurationShort(delay)
} else {
null
}
if (formattedTime != null) {
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
} else {
resources.getString(R.string.too_many_requests_message)
}
}
is TooManyRequestExceptions -> {
val delay = getRetryDelay()
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
resources.formatDurationShort(delay)
} else {
null
}
if (formattedTime != null) {
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
} else {
resources.getString(R.string.too_many_requests_message)
}
}
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> parseMessage(resources) ?: message
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is SyncApiException,
is ContentUnavailableException -> message
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> parseMessage(resources) ?: message
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
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 SyncApiException,
is ContentUnavailableException -> message
is ParseException -> shortMessage
is ConnectException,
is UnknownHostException,
is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error)
is ParseException -> shortMessage
is ConnectException,
is UnknownHostException,
is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error)
is ImageDecodeException -> {
val type = format?.substringBefore('/')
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
if (type.isNullOrEmpty() || type == "image") {
resources.getString(R.string.error_image_format, formatString)
} else {
resources.getString(R.string.error_not_image, formatString)
}
}
is ImageDecodeException -> {
val type = format?.substringBefore('/')
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
if (type.isNullOrEmpty() || type == "image") {
resources.getString(R.string.error_image_format, formatString)
} else {
resources.getString(R.string.error_not_image, formatString)
}
}
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> {
cause?.getDisplayMessageOrNull(resources)?.let {
resources.getString(R.string.plugin_incompatible_with_cause, it)
} ?: resources.getString(R.string.plugin_incompatible)
}
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> {
cause?.getDisplayMessageOrNull(resources)?.let {
resources.getString(R.string.plugin_incompatible_with_cause, it)
} ?: resources.getString(R.string.plugin_incompatible)
}
is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
else -> mapDisplayMessage(message, resources) ?: message
else -> mapDisplayMessage(message, resources) ?: message
}.takeUnless { it.isNullOrBlank() }
@DrawableRes
fun Throwable.getDisplayIcon(): Int = when (this) {
is AuthRequiredException -> R.drawable.ic_auth_key_large
is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException,
is SocketTimeoutException,
is ConnectException,
is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large
is AuthRequiredException -> R.drawable.ic_auth_key_large
is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException,
is SocketTimeoutException,
is ConnectException,
is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
else -> R.drawable.ic_error_large
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
else -> R.drawable.ic_error_large
}
fun Throwable.getCauseUrl(): String? = when (this) {
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
is InteractiveActionRequiredException -> url
is HttpStatusException -> url
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
else -> null
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
is InteractiveActionRequiredException -> 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()
else -> null
}
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> null
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> null
}
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null
msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null
}
fun Throwable.isReportable(): Boolean {
if (this is Error) {
return true
}
if (this is CaughtException) {
return cause.isReportable()
}
if (this is WrapperIOException) {
return cause.isReportable()
}
if (ExceptionResolver.canResolve(this)) {
return false
}
if (this is ParseException
|| this.isNetworkError()
|| this is CloudFlareBlockedException
|| this is CloudFlareProtectedException
|| this is BadBackupFormatException
|| this is WrongPasswordException
|| this is TooManyRequestExceptions
|| this is HttpStatusException
) {
return false
}
return true
if (this is Error) {
return true
}
if (this is CaughtException) {
return cause.isReportable()
}
if (this is WrapperIOException) {
return cause.isReportable()
}
if (ExceptionResolver.canResolve(this)) {
return false
}
if (this is ParseException
|| this.isNetworkError()
|| this is CloudFlareBlockedException
|| this is CloudFlareProtectedException
|| this is BadBackupFormatException
|| this is WrongPasswordException
|| this is TooManyRequestExceptions
|| this is HttpStatusException
) {
return false
}
return true
}
fun Throwable.isNetworkError(): Boolean {
return this is UnknownHostException
|| this is SocketTimeoutException
|| this is StreamResetException
|| this is SocketException
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
return this is UnknownHostException
|| this is SocketTimeoutException
|| this is StreamResetException
|| this is SocketException
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
}
fun Throwable.report(silent: Boolean = false) {
val exception = CaughtException(this)
if (!silent) {
exception.sendWithAcra()
} else if (!BuildConfig.DEBUG) {
exception.sendSilentlyWithAcra()
}
val exception = CaughtException(this)
if (!silent) {
exception.sendWithAcra()
} else if (!BuildConfig.DEBUG) {
exception.sendSilentlyWithAcra()
}
}
fun Throwable.isWebViewUnavailable(): Boolean {
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")
}
@Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
fun FileNotFoundException.getFile(): File? {
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
return groups.getOrNull(1)?.let { File(it) }
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
return groups.getOrNull(1)?.let { File(it) }
}
fun FileNotFoundException.parseMessage(resources: Resources): String? {
/*
Examples:
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
*/
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
val path = groups.getOrNull(1)
val error = groups.getOrNull(2)
val baseMessageIs = when (error) {
"EROFS" -> R.string.no_write_permission_to_file
"ENOENT" -> R.string.file_not_found
else -> return null
}
return if (path.isNullOrEmpty()) {
resources.getString(baseMessageIs)
} else {
resources.getString(
R.string.inline_preference_pattern,
resources.getString(baseMessageIs),
path,
)
}
/*
Examples:
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
*/
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
val path = groups.getOrNull(1)
val error = groups.getOrNull(2)
val baseMessageIs = when (error) {
"EROFS" -> R.string.no_write_permission_to_file
"ENOENT" -> R.string.file_not_found
else -> return null
}
return if (path.isNullOrEmpty()) {
resources.getString(baseMessageIs)
} else {
resources.getString(
R.string.inline_preference_pattern,
resources.getString(baseMessageIs),
path,
)
}
}

View File

@@ -7,111 +7,115 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.data.filterChapters
import java.util.Locale
data class MangaDetails(
private val manga: Manga,
private val localManga: LocalManga?,
private val override: MangaOverride?,
val description: CharSequence?,
val isLoaded: Boolean,
private val manga: Manga,
private val localManga: LocalManga?,
private val override: MangaOverride?,
val description: CharSequence?,
val isLoaded: Boolean,
) {
constructor(manga: Manga) : this(
manga = manga,
localManga = null,
override = null,
description = null,
isLoaded = false,
)
constructor(manga: Manga) : this(
manga = manga,
localManga = null,
override = null,
description = null,
isLoaded = false,
)
val id: Long
get() = manga.id
val id: Long
get() = manga.id
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
val chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch }
}
val chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch }
}
val isLocal
get() = manga.isLocal
val isLocal
get() = manga.isLocal
val local: LocalManga?
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
val local: LocalManga?
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
val coverUrl: String?
get() = override?.coverUrl
.ifNullOrEmpty { manga.largeCoverUrl }
.ifNullOrEmpty { manga.coverUrl }
.ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty()
val coverUrl: String?
get() = override?.coverUrl
.ifNullOrEmpty { manga.largeCoverUrl }
.ifNullOrEmpty { manga.coverUrl }
.ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty()
private val mergedManga by lazy {
if (localManga == null) {
// fast path
manga.withOverride(override)
} else {
manga.copy(
title = override?.title.ifNullOrEmpty { manga.title },
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
contentRating = override?.contentRating ?: manga.contentRating,
chapters = allChapters,
)
}
}
val isRestricted: Boolean
get() = manga.state == MangaState.RESTRICTED
fun toManga() = mergedManga
private val mergedManga by lazy {
if (localManga == null) {
// fast path
manga.withOverride(override)
} else {
manga.copy(
title = override?.title.ifNullOrEmpty { manga.title },
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
contentRating = override?.contentRating ?: manga.contentRating,
chapters = allChapters,
)
}
}
fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
return it
}
return manga.source.getLocale()
}
fun toManga() = mergedManga
fun filterChapters(branch: String?) = copy(
manga = manga.filterChapters(branch),
localManga = localManga?.run {
copy(manga = manga.filterChapters(branch))
},
)
fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
return it
}
return manga.source.getLocale()
}
private fun mergeChapters(): List<MangaChapter> {
val chapters = manga.chapters
val localChapters = local?.manga?.chapters.orEmpty()
if (chapters.isNullOrEmpty()) {
return localChapters
}
val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(chapters.size)
for (chapter in chapters) {
val local = localMap?.remove(chapter.id)
result += local ?: chapter
}
if (!localMap.isNullOrEmpty()) {
result.addAll(localMap.values)
}
return result
}
fun filterChapters(branch: String?) = copy(
manga = manga.filterChapters(branch),
localManga = localManga?.run {
copy(manga = manga.filterChapters(branch))
},
)
private fun findAppropriateLocale(name: String?): Locale? {
if (name.isNullOrEmpty()) {
return null
}
return Locale.getAvailableLocales().find { lc ->
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
}
}
private fun mergeChapters(): List<MangaChapter> {
val chapters = manga.chapters
val localChapters = local?.manga?.chapters.orEmpty()
if (chapters.isNullOrEmpty()) {
return localChapters
}
val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
val result = ArrayList<MangaChapter>(chapters.size)
for (chapter in chapters) {
val local = localMap?.remove(chapter.id)
result += local ?: chapter
}
if (!localMap.isNullOrEmpty()) {
result.addAll(localMap.values)
}
return result
}
private fun findAppropriateLocale(name: String?): Locale? {
if (name.isNullOrEmpty()) {
return null
}
return Locale.getAvailableLocales().find { lc ->
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
}
}
}

View File

@@ -100,7 +100,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
override fun onStateChanged(sheet: View, newState: Int) {
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) {
return
}

View File

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

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui
import android.app.assist.AssistContent
import android.content.DialogInterface
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.Gravity
import android.view.KeyEvent
@@ -24,6 +25,8 @@ import androidx.transition.Fade
import androidx.transition.Slide
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
@@ -31,7 +34,9 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
@@ -70,483 +75,519 @@ import androidx.appcompat.R as appcompatR
@AndroidEntryPoint
class ReaderActivity :
BaseFullscreenActivity<ActivityReaderBinding>(),
TapGridDispatcher.OnGridTouchListener,
ReaderConfigSheet.Callback,
ReaderControlDelegate.OnInteractionListener,
ReaderNavigationCallback,
IdlingDetector.Callback,
ZoomControl.ZoomControlListener,
View.OnClickListener,
ScrollTimerControlView.OnVisibilityChangeListener {
BaseFullscreenActivity<ActivityReaderBinding>(),
TapGridDispatcher.OnGridTouchListener,
ReaderConfigSheet.Callback,
ReaderControlDelegate.OnInteractionListener,
ReaderNavigationCallback,
IdlingDetector.Callback,
ZoomControl.ZoomControlListener,
View.OnClickListener,
ScrollTimerControlView.OnVisibilityChangeListener {
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var tapGridSettings: TapGridSettings
@Inject
lateinit var tapGridSettings: TapGridSettings
@Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
@Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
@Inject
lateinit var scrollTimerFactory: ScrollTimer.Factory
@Inject
lateinit var scrollTimerFactory: ScrollTimer.Factory
@Inject
lateinit var screenOrientationHelper: ScreenOrientationHelper
@Inject
lateinit var screenOrientationHelper: ScreenOrientationHelper
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
private val viewModel: ReaderViewModel by viewModels()
private val viewModel: ReaderViewModel by viewModels()
override val readerMode: ReaderMode?
get() = readerManager.currentMode
override val readerMode: ReaderMode?
get() = readerManager.currentMode
private lateinit var scrollTimer: ScrollTimer
private lateinit var pageSaveHelper: PageSaveHelper
private lateinit var touchHelper: TapGridDispatcher
private lateinit var controlDelegate: ReaderControlDelegate
private var gestureInsets: Insets = Insets.NONE
private lateinit var readerManager: ReaderManager
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
private lateinit var scrollTimer: ScrollTimer
private lateinit var pageSaveHelper: PageSaveHelper
private lateinit var touchHelper: TapGridDispatcher
private lateinit var controlDelegate: ReaderControlDelegate
private var gestureInsets: Insets = Insets.NONE
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, viewBinding.container, settings)
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
touchHelper = TapGridDispatcher(viewBinding.root, this)
scrollTimer = scrollTimerFactory.create(resources, this, this)
pageSaveHelper = pageSaveHelperFactory.create(this)
controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
viewBinding.zoomControl.listener = this
viewBinding.actionsView.listener = this
viewBinding.buttonTimer?.setOnClickListener(this)
idlingDetector.bindToLifecycle(this)
screenOrientationHelper.applySettings()
viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it }
scrollTimer.isActive.observe(this) {
updateScrollTimerButton()
viewBinding.actionsView.setTimerActive(it)
}
viewBinding.timerControl.onVisibilityChangeListener = this
viewBinding.timerControl.attach(scrollTimer, this)
if (resources.getBoolean(R.bool.is_tablet)) {
viewBinding.timerControl.updateLayoutParams<CoordinatorLayout.LayoutParams> {
topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize)
}
}
// Tracks whether the foldable device is in an unfolded state (half-opened or flat)
private var isFoldUnfolded: Boolean = false
viewModel.onLoadingError.observeEvent(
this,
DialogErrorObserver(
host = viewBinding.container,
fragment = null,
resolver = exceptionResolver,
onResolved = { isResolved ->
if (isResolved) {
viewModel.reload()
} else if (viewModel.content.value.pages.isEmpty()) {
dispatchNavigateUp()
}
},
),
)
viewModel.onError.observeEvent(
this,
SnackbarErrorObserver(
host = viewBinding.container,
fragment = null,
resolver = exceptionResolver,
onResolved = null,
),
)
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
combine(
viewModel.isLoading,
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
::Pair,
).flowOn(Dispatchers.Default)
.observe(this, this::onLoadingStateChanged)
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
viewModel.onAskNsfwIncognito.observeEvent(this) { askForIncognitoMode() }
viewModel.onShowToast.observeEvent(this) { msgId ->
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
.setAnchorView(viewBinding.toolbarDocked)
.show()
}
viewModel.readerSettingsProducer.observe(this) {
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
}
viewModel.isZoomControlsEnabled.observe(this) {
viewBinding.zoomControl.isVisible = it
}
addMenuProvider(ReaderMenuProvider(viewModel))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater))
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
touchHelper = TapGridDispatcher(viewBinding.root, this)
scrollTimer = scrollTimerFactory.create(resources, this, this)
pageSaveHelper = pageSaveHelperFactory.create(this)
controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
viewBinding.zoomControl.listener = this
viewBinding.actionsView.listener = this
viewBinding.buttonTimer?.setOnClickListener(this)
idlingDetector.bindToLifecycle(this)
screenOrientationHelper.applySettings()
viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it }
scrollTimer.isActive.observe(this) {
updateScrollTimerButton()
viewBinding.actionsView.setTimerActive(it)
}
viewBinding.timerControl.onVisibilityChangeListener = this
viewBinding.timerControl.attach(scrollTimer, this)
if (resources.getBoolean(R.bool.is_tablet)) {
viewBinding.timerControl.updateLayoutParams<CoordinatorLayout.LayoutParams> {
topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize)
}
}
override fun getParentActivityIntent(): Intent? {
val manga = viewModel.getMangaOrNull() ?: return null
return AppRouter.detailsIntent(this, manga)
}
viewModel.onLoadingError.observeEvent(
this,
DialogErrorObserver(
host = viewBinding.container,
fragment = null,
resolver = exceptionResolver,
onResolved = { isResolved ->
if (isResolved) {
viewModel.reload()
} else if (viewModel.content.value.pages.isEmpty()) {
dispatchNavigateUp()
}
},
),
)
viewModel.onError.observeEvent(
this,
SnackbarErrorObserver(
host = viewBinding.container,
fragment = null,
resolver = exceptionResolver,
onResolved = null,
),
)
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
combine(
viewModel.isLoading,
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
::Pair,
).flowOn(Dispatchers.Default)
.observe(this, this::onLoadingStateChanged)
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
viewModel.onAskNsfwIncognito.observeEvent(this) { askForIncognitoMode() }
viewModel.onShowToast.observeEvent(this) { msgId ->
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
.setAnchorView(viewBinding.toolbarDocked)
.show()
}
viewModel.readerSettingsProducer.observe(this) {
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
}
viewModel.isZoomControlsEnabled.observe(this) {
viewBinding.zoomControl.isVisible = it
}
addMenuProvider(ReaderMenuProvider(viewModel))
override fun onUserInteraction() {
super.onUserInteraction()
if (!viewBinding.timerControl.isVisible) {
scrollTimer.onUserInteraction()
}
idlingDetector.onUserInteraction()
}
observeWindowLayout()
override fun onPause() {
super.onPause()
viewModel.onPause()
}
// Apply initial double-mode considering foldable setting
applyDoubleModeAuto()
}
override fun onStop() {
super.onStop()
viewModel.onStop()
}
override fun getParentActivityIntent(): Intent? {
val manga = viewModel.getMangaOrNull() ?: return null
return AppRouter.detailsIntent(this, manga)
}
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
}
override fun onUserInteraction() {
super.onUserInteraction()
if (!viewBinding.timerControl.isVisible) {
scrollTimer.onUserInteraction()
}
idlingDetector.onUserInteraction()
}
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
override fun onPause() {
super.onPause()
viewModel.onPause()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.onIdle()
}
override fun onStop() {
super.onStop()
viewModel.onStop()
}
override fun onVisibilityChanged(v: View, visibility: Int) {
updateScrollTimerButton()
}
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
}
override fun onZoomIn() {
readerManager.currentReader?.onZoomIn()
}
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
override fun onZoomOut() {
readerManager.currentReader?.onZoomOut()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.onIdle()
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_timer -> onScrollTimerClick(isLongClick = false)
}
}
override fun onVisibilityChanged(v: View, visibility: Int) {
updateScrollTimerButton()
}
private fun onInitReader(mode: ReaderMode?) {
if (mode == null) {
return
}
if (readerManager.currentMode != mode) {
readerManager.replace(mode)
}
if (viewBinding.appbarTop.isVisible) {
lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable)
}
viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED)
viewBinding.timerControl.onReaderModeChanged(mode)
}
override fun onZoomIn() {
readerManager.currentReader?.onZoomIn()
}
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
val (isLoading, hasPages) = value
val showLoadingLayout = isLoading && !hasPages
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
val transition = Fade().addTarget(viewBinding.layoutLoading)
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
viewBinding.layoutLoading.isVisible = showLoadingLayout
}
if (isLoading && hasPages) {
viewBinding.toastView.show(R.string.loading_)
} else {
viewBinding.toastView.hide()
}
invalidateOptionsMenu()
}
override fun onZoomOut() {
readerManager.currentReader?.onZoomOut()
}
override fun onGridTouch(area: TapGridArea): Boolean {
return isReaderResumed() && controlDelegate.onGridTouch(area)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_timer -> onScrollTimerClick(isLongClick = false)
}
}
override fun onGridLongTouch(area: TapGridArea) {
if (isReaderResumed()) {
controlDelegate.onGridLongTouch(area)
}
}
private fun onInitReader(mode: ReaderMode?) {
if (mode == null) {
return
}
if (readerManager.currentMode != mode) {
readerManager.replace(mode)
}
if (viewBinding.appbarTop.isVisible) {
lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable)
}
viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED)
viewBinding.timerControl.onReaderModeChanged(mode)
}
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
return if (
rawX <= gestureInsets.left ||
rawY <= gestureInsets.top ||
rawX >= viewBinding.root.width - gestureInsets.right ||
rawY >= viewBinding.root.height - gestureInsets.bottom ||
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) ||
viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true
) {
false
} else {
val touchables = window.peekDecorView()?.touchables
touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false
}
}
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
val (isLoading, hasPages) = value
val showLoadingLayout = isLoading && !hasPages
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
val transition = Fade().addTarget(viewBinding.layoutLoading)
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
viewBinding.layoutLoading.isVisible = showLoadingLayout
}
if (isLoading && hasPages) {
viewBinding.toastView.show(R.string.loading_)
} else {
viewBinding.toastView.hide()
}
invalidateOptionsMenu()
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
touchHelper.dispatchTouchEvent(ev)
if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) {
scrollTimer.onTouchEvent(ev)
}
return super.dispatchTouchEvent(ev)
}
override fun onGridTouch(area: TapGridArea): Boolean {
return isReaderResumed() && controlDelegate.onGridTouch(area)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
}
override fun onGridLongTouch(area: TapGridArea) {
if (isReaderResumed()) {
controlDelegate.onGridLongTouch(area)
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
}
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
return if (
rawX <= gestureInsets.left ||
rawY <= gestureInsets.top ||
rawX >= viewBinding.root.width - gestureInsets.right ||
rawY >= viewBinding.root.height - gestureInsets.bottom ||
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) ||
viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true
) {
false
} else {
val touchables = window.peekDecorView()?.touchables
touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false
}
}
override fun onChapterSelected(chapter: MangaChapter): Boolean {
viewModel.switchChapter(chapter.id, 0)
return true
}
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
touchHelper.dispatchTouchEvent(ev)
if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) {
scrollTimer.onTouchEvent(ev)
}
return super.dispatchTouchEvent(ev)
}
override fun onPageSelected(page: ReaderPage): Boolean {
lifecycleScope.launch(Dispatchers.Default) {
val pages = viewModel.content.value.pages
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
if (index != -1) {
withContext(Dispatchers.Main) {
readerManager.currentReader?.switchPageTo(index, true)
}
} else {
viewModel.switchChapter(page.chapterId, page.index)
}
}
return true
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
}
override fun onReaderModeChanged(mode: ReaderMode) {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.switchMode(mode)
viewBinding.timerControl.onReaderModeChanged(mode)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
}
override fun onDoubleModeChanged(isEnabled: Boolean) {
readerManager.setDoubleReaderMode(isEnabled)
}
override fun onChapterSelected(chapter: MangaChapter): Boolean {
viewModel.switchChapter(chapter.id, 0)
return true
}
private fun setKeepScreenOn(isKeep: Boolean) {
if (isKeep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
override fun onPageSelected(page: ReaderPage): Boolean {
lifecycleScope.launch(Dispatchers.Default) {
val pages = viewModel.content.value.pages
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
if (index != -1) {
withContext(Dispatchers.Main) {
readerManager.currentReader?.switchPageTo(index, true)
}
} else {
viewModel.switchChapter(page.chapterId, page.index)
}
}
return true
}
private fun setUiIsVisible(isUiVisible: Boolean) {
if (viewBinding.appbarTop.isVisible != isUiVisible) {
if (isAnimationsEnabled) {
val transition = TransitionSet()
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
.addTransition(Fade().addTarget(viewBinding.infoBar))
viewBinding.toolbarDocked?.let {
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it))
}
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
}
val isFullscreen = settings.isReaderFullscreenEnabled
viewBinding.appbarTop.isVisible = isUiVisible
viewBinding.toolbarDocked?.isVisible = isUiVisible
viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
viewBinding.infoBar.isTimeVisible = isFullscreen
updateScrollTimerButton()
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
viewBinding.root.requestApplyInsets()
}
}
override fun onReaderModeChanged(mode: ReaderMode) {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.switchMode(mode)
viewBinding.timerControl.onReaderModeChanged(mode)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemBars.top
rightMargin = systemBars.right
leftMargin = systemBars.left
}
if (viewBinding.toolbarDocked != null) {
viewBinding.actionsView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = systemBars.bottom
rightMargin = systemBars.right
leftMargin = systemBars.left
}
}
viewBinding.infoBar.updatePadding(
top = systemBars.top,
)
val innerInsets = Insets.of(
systemBars.left,
if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top,
systemBars.right,
viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom,
)
return WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets)
.build()
}
override fun onDoubleModeChanged(isEnabled: Boolean) {
// Combine manual toggle with foldable auto setting
applyDoubleModeAuto(isEnabled)
}
override fun switchPageBy(delta: Int) {
readerManager.currentReader?.switchPageBy(delta)
}
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)
}
override fun switchChapterBy(delta: Int) {
viewModel.switchChapterBy(delta)
}
private fun setKeepScreenOn(isKeep: Boolean) {
if (isKeep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
override fun openMenu() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
val currentMode = readerManager.currentMode ?: return
router.showReaderConfigSheet(currentMode)
}
private fun setUiIsVisible(isUiVisible: Boolean) {
if (viewBinding.appbarTop.isVisible != isUiVisible) {
if (isAnimationsEnabled) {
val transition = TransitionSet()
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
.addTransition(Fade().addTarget(viewBinding.infoBar))
viewBinding.toolbarDocked?.let {
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it))
}
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
}
val isFullscreen = settings.isReaderFullscreenEnabled
viewBinding.appbarTop.isVisible = isUiVisible
viewBinding.toolbarDocked?.isVisible = isUiVisible
viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
viewBinding.infoBar.isTimeVisible = isFullscreen
updateScrollTimerButton()
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
viewBinding.root.requestApplyInsets()
}
}
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
return readerManager.currentReader?.scrollBy(delta, smooth) == true
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemBars.top
rightMargin = systemBars.right
leftMargin = systemBars.left
}
if (viewBinding.toolbarDocked != null) {
viewBinding.actionsView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = systemBars.bottom
rightMargin = systemBars.right
leftMargin = systemBars.left
}
}
viewBinding.infoBar.updatePadding(
top = systemBars.top,
)
val innerInsets = Insets.of(
systemBars.left,
if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top,
systemBars.right,
viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom,
)
return WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets)
.build()
}
override fun toggleUiVisibility() {
setUiIsVisible(!viewBinding.appbarTop.isVisible)
}
override fun switchPageBy(delta: Int) {
readerManager.currentReader?.switchPageBy(delta)
}
override fun isReaderResumed(): Boolean {
val reader = readerManager.currentReader ?: return false
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
}
override fun switchChapterBy(delta: Int) {
viewModel.switchChapterBy(delta)
}
override fun onBookmarkClick() {
viewModel.toggleBookmark()
}
override fun openMenu() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
val currentMode = readerManager.currentMode ?: return
router.showReaderConfigSheet(currentMode)
}
override fun onSavePageClick() {
viewModel.saveCurrentPage(pageSaveHelper)
}
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
return readerManager.currentReader?.scrollBy(delta, smooth) == true
}
override fun onScrollTimerClick(isLongClick: Boolean) {
if (isLongClick) {
scrollTimer.setActive(!scrollTimer.isActive.value)
} else {
viewBinding.timerControl.showOrHide()
}
}
override fun toggleUiVisibility() {
setUiIsVisible(!viewBinding.appbarTop.isVisible)
}
override fun toggleScreenOrientation() {
if (screenOrientationHelper.toggleScreenOrientation()) {
Snackbar.make(
viewBinding.container,
if (screenOrientationHelper.isLocked) {
R.string.screen_rotation_locked
} else {
R.string.screen_rotation_unlocked
},
Snackbar.LENGTH_SHORT,
).setAnchorView(viewBinding.toolbarDocked)
.show()
}
}
override fun isReaderResumed(): Boolean {
val reader = readerManager.currentReader ?: return false
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
}
override fun switchPageTo(index: Int) {
val pages = viewModel.getCurrentChapterPages()
val page = pages?.getOrNull(index) ?: return
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
onPageSelected(ReaderPage(page, index, chapterId))
}
override fun onBookmarkClick() {
viewModel.toggleBookmark()
}
private fun onReaderBarChanged(isBarEnabled: Boolean) {
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
}
override fun onSavePageClick() {
viewModel.saveCurrentPage(pageSaveHelper)
}
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair
title = uiState?.mangaName ?: getString(R.string.loading_)
viewBinding.infoBar.update(uiState)
if (uiState == null) {
supportActionBar?.subtitle = null
viewBinding.actionsView.setSliderValue(0, 1)
viewBinding.actionsView.isSliderEnabled = false
return
}
val chapterTitle = uiState.getChapterTitle(resources)
supportActionBar?.subtitle = when {
uiState.incognito -> getString(R.string.incognito_mode)
else -> chapterTitle
}
if (
settings.isReaderChapterToastEnabled &&
chapterTitle != previous?.getChapterTitle(resources) &&
chapterTitle.isNotEmpty()
) {
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
}
if (uiState.isSliderAvailable()) {
viewBinding.actionsView.setSliderValue(
value = uiState.currentPage,
max = uiState.totalPages - 1,
)
} else {
viewBinding.actionsView.setSliderValue(0, 1)
}
viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable()
viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter()
viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter()
}
override fun onScrollTimerClick(isLongClick: Boolean) {
if (isLongClick) {
scrollTimer.setActive(!scrollTimer.isActive.value)
} else {
viewBinding.timerControl.showOrHide()
}
}
private fun updateScrollTimerButton() {
val button = viewBinding.buttonTimer ?: return
val isButtonVisible = scrollTimer.isActive.value
&& settings.isReaderAutoscrollFabVisible
&& !viewBinding.appbarTop.isVisible
&& !viewBinding.timerControl.isVisible
if (button.isVisible != isButtonVisible) {
val transition = Fade().addTarget(button)
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
button.isVisible = isButtonVisible
}
}
override fun toggleScreenOrientation() {
if (screenOrientationHelper.toggleScreenOrientation()) {
Snackbar.make(
viewBinding.container,
if (screenOrientationHelper.isLocked) {
R.string.screen_rotation_locked
} else {
R.string.screen_rotation_unlocked
},
Snackbar.LENGTH_SHORT,
).setAnchorView(viewBinding.toolbarDocked)
.show()
}
}
private fun askForIncognitoMode() {
buildAlertDialog(this, isCentered = true) {
var dontAskAgain = false
val listener = DialogInterface.OnClickListener { _, which ->
if (which == DialogInterface.BUTTON_NEUTRAL) {
finishAfterTransition()
} else {
viewModel.setIncognitoMode(which == DialogInterface.BUTTON_POSITIVE, dontAskAgain)
}
}
setCheckbox(R.string.dont_ask_again, dontAskAgain) { _, isChecked ->
dontAskAgain = isChecked
}
setIcon(R.drawable.ic_incognito)
setTitle(R.string.incognito_mode)
setMessage(R.string.incognito_mode_hint_nsfw)
setPositiveButton(R.string.incognito, listener)
setNegativeButton(R.string.disable, listener)
setNeutralButton(android.R.string.cancel, listener)
setOnCancelListener { finishAfterTransition() }
setCancelable(true)
}.show()
}
override fun switchPageTo(index: Int) {
val pages = viewModel.getCurrentChapterPages()
val page = pages?.getOrNull(index) ?: return
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
onPageSelected(ReaderPage(page, index, chapterId))
}
companion object {
private fun onReaderBarChanged(isBarEnabled: Boolean) {
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
}
private const val TOAST_DURATION = 2000L
}
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair
title = uiState?.mangaName ?: getString(R.string.loading_)
viewBinding.infoBar.update(uiState)
if (uiState == null) {
supportActionBar?.subtitle = null
viewBinding.actionsView.setSliderValue(0, 1)
viewBinding.actionsView.isSliderEnabled = false
return
}
val chapterTitle = uiState.getChapterTitle(resources)
supportActionBar?.subtitle = when {
uiState.incognito -> getString(R.string.incognito_mode)
else -> chapterTitle
}
if (
settings.isReaderChapterToastEnabled &&
chapterTitle != previous?.getChapterTitle(resources) &&
chapterTitle.isNotEmpty()
) {
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
}
if (uiState.isSliderAvailable()) {
viewBinding.actionsView.setSliderValue(
value = uiState.currentPage,
max = uiState.totalPages - 1,
)
} else {
viewBinding.actionsView.setSliderValue(0, 1)
}
viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable()
viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter()
viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter()
}
private fun updateScrollTimerButton() {
val button = viewBinding.buttonTimer ?: return
val isButtonVisible = scrollTimer.isActive.value
&& settings.isReaderAutoscrollFabVisible
&& !viewBinding.appbarTop.isVisible
&& !viewBinding.timerControl.isVisible
if (button.isVisible != isButtonVisible) {
val transition = Fade().addTarget(button)
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
button.isVisible = isButtonVisible
}
}
// 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() {
buildAlertDialog(this, isCentered = true) {
var dontAskAgain = false
val listener = DialogInterface.OnClickListener { _, which ->
if (which == DialogInterface.BUTTON_NEUTRAL) {
finishAfterTransition()
} else {
viewModel.setIncognitoMode(which == DialogInterface.BUTTON_POSITIVE, dontAskAgain)
}
}
setCheckbox(R.string.dont_ask_again, dontAskAgain) { _, isChecked ->
dontAskAgain = isChecked
}
setIcon(R.drawable.ic_incognito)
setTitle(R.string.incognito_mode)
setMessage(R.string.incognito_mode_hint_nsfw)
setPositiveButton(R.string.incognito, listener)
setNegativeButton(R.string.disable, listener)
setNeutralButton(android.R.string.cancel, listener)
setOnCancelListener { finishAfterTransition() }
setCancelable(true)
}.show()
}
companion object {
private const val TOAST_DURATION = 2000L
}
}

View File

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

View File

@@ -38,228 +38,244 @@ import javax.inject.Inject
@AndroidEntryPoint
class ReaderConfigSheet :
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener,
CompoundButton.OnCheckedChangeListener,
Slider.OnChangeListener {
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener,
CompoundButton.OnCheckedChangeListener,
Slider.OnChangeListener {
private val viewModel by activityViewModels<ReaderViewModel>()
private val viewModel by activityViewModels<ReaderViewModel>()
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject
lateinit var pageLoader: PageLoader
@Inject
lateinit var pageLoader: PageLoader
private lateinit var mode: ReaderMode
private lateinit var imageServerDelegate: ImageServerDelegate
private lateinit var mode: ReaderMode
private lateinit var imageServerDelegate: ImageServerDelegate
@Inject
lateinit var settings: AppSettings
@Inject
lateinit var settings: AppSettings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
?.let { ReaderMode.valueOf(it) }
?: ReaderMode.STANDARD
imageServerDelegate = ImageServerDelegate(
mangaRepositoryFactory = mangaRepositoryFactory,
mangaSource = viewModel.getMangaOrNull()?.source,
)
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): SheetReaderConfigBinding {
return SheetReaderConfigBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(
binding: SheetReaderConfigBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
observeScreenOrientation()
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
binding.adjustSensitivitySlider(withAnimation = false)
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.buttonSavePage.setOnClickListener(this)
binding.buttonScreenRotate.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.buttonImageServer.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this)
binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.sliderDoubleSensitivity.addOnChangeListener(this)
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
)
}
viewLifecycleScope.launch {
val isAvailable = imageServerDelegate.isAvailable()
if (isAvailable) {
bindImageServerTitle()
}
binding.buttonImageServer.isVisible = isAvailable
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.scrollView?.updatePadding(
bottom = insets.getInsets(typeMask).bottom,
)
return insets.consume(v, typeMask, bottom = true)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_settings -> {
router.openReaderSettings()
dismissAllowingStateLoss()
}
R.id.button_scroll_timer -> {
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
dismissAllowingStateLoss()
}
R.id.button_save_page -> {
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
dismissAllowingStateLoss()
}
R.id.button_screen_rotate -> {
orientationHelper.isLandscape = !orientationHelper.isLandscape
}
R.id.button_bookmark -> {
viewModel.toggleBookmark()
}
R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.openColorFilterConfig(manga, page)
}
R.id.button_image_server -> viewLifecycleScope.launch {
if (imageServerDelegate.showDialog(v.context)) {
bindImageServerTitle()
pageLoader.invalidate(clearCache = true)
viewModel.switchChapterBy(0)
}
}
}
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView.id) {
R.id.switch_screen_lock_rotation -> {
orientationHelper.isLocked = isChecked
}
R.id.switch_double_reader -> {
settings.isReaderDoubleOnLandscape = isChecked
viewBinding?.adjustSensitivitySlider(withAnimation = true)
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
}
}
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
settings.readerDoublePagesSensitivity = value / 100f
}
override fun onButtonChecked(
group: MaterialButtonToggleGroup?,
checkedId: Int,
isChecked: Boolean,
) {
if (!isChecked) {
return
}
val newMode = when (checkedId) {
R.id.button_standard -> ReaderMode.STANDARD
R.id.button_webtoon -> ReaderMode.WEBTOON
R.id.button_reversed -> ReaderMode.REVERSED
R.id.button_vertical -> ReaderMode.VERTICAL
else -> return
}
viewBinding?.run {
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
adjustSensitivitySlider(withAnimation = true)
}
if (newMode == mode) {
return
}
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
mode = newMode
}
private fun observeScreenOrientation() {
orientationHelper.observeAutoOrientation()
.onEach {
with(requireViewBinding()) {
buttonScreenRotate.isGone = it
switchScreenLockRotation.isVisible = it
updateOrientationLockSwitch()
}
}.launchIn(viewLifecycleScope)
}
private fun updateOrientationLockSwitch() {
val switch = viewBinding?.switchScreenLockRotation ?: return
switch.setOnCheckedChangeListener(null)
switch.isChecked = orientationHelper.isLocked
switch.setOnCheckedChangeListener(this)
}
private suspend fun bindImageServerTitle() {
viewBinding?.buttonImageServer?.text = getString(
R.string.inline_preference_pattern,
getString(R.string.image_server),
imageServerDelegate.getValue() ?: getString(R.string.automatic),
)
}
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
val isSliderVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
if (isSliderVisible != sliderDoubleSensitivity.isVisible && withAnimation) {
TransitionManager.beginDelayedTransition(layoutMain)
}
sliderDoubleSensitivity.isVisible = isSliderVisible
textDoubleSensitivity.isVisible = isSliderVisible
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
?.let { ReaderMode.valueOf(it) }
?: ReaderMode.STANDARD
imageServerDelegate = ImageServerDelegate(
mangaRepositoryFactory = mangaRepositoryFactory,
mangaSource = viewModel.getMangaOrNull()?.source,
)
}
interface Callback {
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): SheetReaderConfigBinding {
return SheetReaderConfigBinding.inflate(inflater, container, false)
}
fun onReaderModeChanged(mode: ReaderMode)
override fun onViewBindingCreated(
binding: SheetReaderConfigBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
observeScreenOrientation()
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
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.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
binding.adjustSensitivitySlider(withAnimation = false)
fun onDoubleModeChanged(isEnabled: Boolean)
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.buttonSavePage.setOnClickListener(this)
binding.buttonScreenRotate.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.buttonImageServer.setOnClickListener(this)
binding.buttonColorFilter.setOnClickListener(this)
binding.buttonScrollTimer.setOnClickListener(this)
binding.buttonBookmark.setOnClickListener(this)
binding.switchDoubleReader.setOnCheckedChangeListener(this)
binding.switchDoubleFoldable.setOnCheckedChangeListener(this)
binding.sliderDoubleSensitivity.addOnChangeListener(this)
fun onSavePageClick()
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
)
}
fun onScrollTimerClick(isLongClick: Boolean)
viewLifecycleScope.launch {
val isAvailable = imageServerDelegate.isAvailable()
if (isAvailable) {
bindImageServerTitle()
}
binding.buttonImageServer.isVisible = isAvailable
}
}
fun onBookmarkClick()
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
viewBinding?.scrollView?.updatePadding(
bottom = insets.getInsets(typeMask).bottom,
)
return insets.consume(v, typeMask, bottom = true)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_settings -> {
router.openReaderSettings()
dismissAllowingStateLoss()
}
R.id.button_scroll_timer -> {
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
dismissAllowingStateLoss()
}
R.id.button_save_page -> {
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
dismissAllowingStateLoss()
}
R.id.button_screen_rotate -> {
orientationHelper.isLandscape = !orientationHelper.isLandscape
}
R.id.button_bookmark -> {
viewModel.toggleBookmark()
}
R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.getMangaOrNull() ?: return
router.openColorFilterConfig(manga, page)
}
R.id.button_image_server -> viewLifecycleScope.launch {
if (imageServerDelegate.showDialog(v.context)) {
bindImageServerTitle()
pageLoader.invalidate(clearCache = true)
viewModel.switchChapterBy(0)
}
}
}
}
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
when (buttonView.id) {
R.id.switch_screen_lock_rotation -> {
orientationHelper.isLocked = isChecked
}
R.id.switch_double_reader -> {
settings.isReaderDoubleOnLandscape = isChecked
viewBinding?.adjustSensitivitySlider(withAnimation = true)
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)
}
}
}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
settings.readerDoublePagesSensitivity = value / 100f
}
override fun onButtonChecked(
group: MaterialButtonToggleGroup?,
checkedId: Int,
isChecked: Boolean,
) {
if (!isChecked) {
return
}
val newMode = when (checkedId) {
R.id.button_standard -> ReaderMode.STANDARD
R.id.button_webtoon -> ReaderMode.WEBTOON
R.id.button_reversed -> ReaderMode.REVERSED
R.id.button_vertical -> ReaderMode.VERTICAL
else -> return
}
viewBinding?.run {
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled
adjustSensitivitySlider(withAnimation = true)
}
if (newMode == mode) {
return
}
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
mode = newMode
}
private fun observeScreenOrientation() {
orientationHelper.observeAutoOrientation()
.onEach {
with(requireViewBinding()) {
buttonScreenRotate.isGone = it
switchScreenLockRotation.isVisible = it
updateOrientationLockSwitch()
}
}.launchIn(viewLifecycleScope)
}
private fun updateOrientationLockSwitch() {
val switch = viewBinding?.switchScreenLockRotation ?: return
switch.setOnCheckedChangeListener(null)
switch.isChecked = orientationHelper.isLocked
switch.setOnCheckedChangeListener(this)
}
private suspend fun bindImageServerTitle() {
viewBinding?.buttonImageServer?.text = getString(
R.string.inline_preference_pattern,
getString(R.string.image_server),
imageServerDelegate.getValue() ?: getString(R.string.automatic),
)
}
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
val isSubOptionsVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
val needTransition = withAnimation && (
(isSubOptionsVisible != sliderDoubleSensitivity.isVisible) ||
(isSubOptionsVisible != textDoubleSensitivity.isVisible) ||
(isSubOptionsVisible != switchDoubleFoldable.isVisible)
)
if (needTransition) {
TransitionManager.beginDelayedTransition(layoutMain)
}
sliderDoubleSensitivity.isVisible = isSubOptionsVisible
textDoubleSensitivity.isVisible = isSubOptionsVisible
switchDoubleFoldable.isVisible = isSubOptionsVisible
}
interface Callback {
fun onReaderModeChanged(mode: ReaderMode)
fun onDoubleModeChanged(isEnabled: Boolean)
fun onSavePageClick()
fun onScrollTimerClick(isLongClick: Boolean)
fun onBookmarkClick()
}
}

View File

@@ -25,11 +25,26 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
readerAdapter = onCreateAdapter()
viewModel.content.observe(viewLifecycleOwner) {
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
onPagesChanged(it.pages, viewModel.getCurrentState())
} else {
onPagesChanged(it.pages, it.state)
// Determine which state to use for restoring position:
// - content.state: explicitly set state (e.g., after mode switch or chapter change)
// - getCurrentState(): current reading position saved in SavedStateHandle
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)
supportActionBar?.setSubtitle(R.string.search_results)
addMenuProvider(SearchKindMenuProvider(this, viewModel.query, viewModel.kind))
addMenuProvider(SearchMenuProvider(this, viewModel))
viewModel.list.observe(this, adapter)
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.search.domain.SearchKind
class SearchKindMenuProvider(
class SearchMenuProvider(
private val activity: SearchActivity,
private val query: String,
private val kind: SearchKind
private val viewModel: SearchViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -22,7 +21,7 @@ class SearchKindMenuProvider(
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(
when (kind) {
when (viewModel.kind) {
SearchKind.SIMPLE -> R.id.action_kind_simple
SearchKind.TITLE -> R.id.action_kind_title
SearchKind.AUTHOR -> R.id.action_kind_author
@@ -32,6 +31,20 @@ class SearchKindMenuProvider(
}
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) {
R.id.action_kind_simple -> SearchKind.SIMPLE
R.id.action_kind_title -> SearchKind.TITLE
@@ -39,9 +52,9 @@ class SearchKindMenuProvider(
R.id.action_kind_tag -> SearchKind.TAG
else -> return false
}
if (newKind != kind) {
if (newKind != viewModel.kind) {
activity.router.openSearch(
query = query,
query = viewModel.query,
kind = newKind,
)
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
private var includeDisabledSources = MutableStateFlow(false)
private var pinnedOnly = MutableStateFlow(false)
private var hideEmpty = MutableStateFlow(false)
private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList())
private var searchJob: Job? = null
@@ -70,9 +72,15 @@ class SearchViewModel @Inject constructor(
results,
isLoading.dropWhile { !it },
includeDisabledSources,
) { list, loading, includeDisabled ->
hideEmpty,
) { list, loading, includeDisabled, hideEmptyVal ->
val filteredList = if (hideEmptyVal) {
list.filter { it.list.isNotEmpty() }
} else {
list
}
when {
list.isEmpty() -> listOf(
filteredList.isEmpty() -> listOf(
when {
loading -> LoadingState
else -> EmptyState(
@@ -84,9 +92,9 @@ class SearchViewModel @Inject constructor(
},
)
loading -> list + LoadingFooter()
includeDisabled -> list
else -> list + ButtonFooter(R.string.search_disabled_sources)
loading -> filteredList + LoadingFooter()
includeDisabled -> filteredList
else -> filteredList + ButtonFooter(R.string.search_disabled_sources)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
@@ -114,6 +122,17 @@ class SearchViewModel @Inject constructor(
doSearch()
}
fun setPinnedOnly(value: Boolean) {
if (pinnedOnly.value != value) {
pinnedOnly.value = value
retry()
}
}
fun setHideEmpty(value: Boolean) {
hideEmpty.value = value
}
fun continueSearch() {
if (includeDisabledSources.value) {
return
@@ -122,8 +141,12 @@ class SearchViewModel @Inject constructor(
searchJob = launchLoadingJob(Dispatchers.Default) {
includeDisabledSources.value = true
prevJob?.join()
val sources = sourcesRepository.getDisabledSources()
.sortedByDescending { it.priority() }
val sources = if (pinnedOnly.value) {
emptyList()
} else {
sourcesRepository.getDisabledSources()
.sortedByDescending { it.priority() }
}
val semaphore = Semaphore(MAX_PARALLELISM)
sources.map { source ->
launch {
@@ -142,7 +165,11 @@ class SearchViewModel @Inject constructor(
appendResult(searchHistory())
appendResult(searchFavorites())
appendResult(searchLocal())
val sources = sourcesRepository.getEnabledSources()
val sources = if (pinnedOnly.value) {
sourcesRepository.getPinnedSources().toList()
} else {
sourcesRepository.getEnabledSources()
}
val semaphore = Semaphore(MAX_PARALLELISM)
sources.map { source ->
launch {

View File

@@ -130,6 +130,21 @@
android:textColor="?colorOnSurfaceVariant"
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
android:id="@+id/text_double_sensitivity"
android:layout_width="match_parent"

View File

@@ -33,6 +33,20 @@
android:title="@string/genre" />
</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>
</item>

View File

@@ -879,4 +879,6 @@
<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="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>

View File

@@ -883,4 +883,6 @@
<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="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>

View File

@@ -881,4 +881,7 @@
<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="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>

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="available_pattern">%1$s var</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>

View File

@@ -880,4 +880,11 @@
<string name="data_removal">Вилучення даних</string>
<string name="privacy">Конфіденційність</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>

View File

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

View File

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

View File

@@ -210,6 +210,8 @@
<string name="disabled">Disabled</string>
<string name="reset_filter">Reset filter</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="never">Never</string>
<string name="only_using_wifi">Only on Wi-Fi</string>
@@ -579,6 +581,7 @@
<string name="none">None</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="auto_double_foldable">Auto Two-Page On Foldable</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="fullscreen_mode">Fullscreen mode</string>

View File

@@ -49,6 +49,7 @@ viewpager2 = "1.1.0"
webkit = "1.14.0"
workRuntime = "2.10.5"
workinspector = "1.2"
window = "1.3.0"
[libraries]
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" }
ssiv = { module = "com.github.KotatsuApp:subsampling-scale-image-view", version.ref = "ssiv" }
workinspector = { module = "com.github.Koitharu:WorkInspector", version.ref = "workinspector" }
androidx-window = { module = "androidx.window:window", version.ref = "window" }
[plugins]
android-application = { id = "com.android.application", version.ref = "gradle" }