Compare commits

..

25 Commits
v9.4 ... devel

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

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

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

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

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

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

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (894 of 894 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (893 of 893 strings)

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

View File

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

View File

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

View File

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

View File

@@ -8,13 +8,15 @@ import androidx.collection.MutableScatterMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import dagger.assisted.Assisted import androidx.lifecycle.Lifecycle
import dagger.assisted.AssistedFactory import androidx.lifecycle.LifecycleOwner
import dagger.assisted.AssistedInject import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.async
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
@@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.restartApplication import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -32,164 +35,221 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException import java.security.cert.CertPathValidatorException
import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class ExceptionResolver @AssistedInject constructor( class ExceptionResolver private constructor(
@Assisted private val host: Host, private val host: Host,
private val settings: AppSettings, private val settings: AppSettings,
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>, private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
) { ) {
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1) private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) { private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
handleActivityResult(BrowserActivity.TAG, true) handleActivityResult(BrowserActivity.TAG, true)
} }
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) { private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
handleActivityResult(SourceAuthActivity.TAG, it) handleActivityResult(SourceAuthActivity.TAG, it)
} }
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) { private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
handleActivityResult(CloudFlareActivity.TAG, it) handleActivityResult(CloudFlareActivity.TAG, it)
} }
fun showErrorDetails(e: Throwable, url: String? = null) { fun showErrorDetails(e: Throwable, url: String? = null) {
host.router()?.showErrorDialog(e, url) host.router.showErrorDialog(e, url)
} }
suspend fun resolve(e: Throwable): Boolean = when (e) { suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
is CloudFlareProtectedException -> resolveCF(e) when (e) {
is AuthRequiredException -> resolveAuthException(e.source) is CloudFlareProtectedException -> resolveCF(e)
is SSLException, is AuthRequiredException -> resolveAuthException(e.source)
is CertPathValidatorException -> { is SSLException,
showSslErrorDialog() is CertPathValidatorException -> {
false showSslErrorDialog()
} false
}
is InteractiveActionRequiredException -> resolveBrowserAction(e) is InteractiveActionRequiredException -> resolveBrowserAction(e)
is ProxyConfigException -> { is ProxyConfigException -> {
host.router()?.openProxySettings() host.router.openProxySettings()
false false
} }
is NotFoundException -> { is NotFoundException -> {
openInBrowser(e.url) openInBrowser(e.url)
false false
} }
is UnsupportedSourceException -> { is EmptyMangaException -> {
e.manga?.let { openAlternatives(it) } when (e.reason) {
false EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
} EmptyMangaReason.LOADING_ERROR -> Unit
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
else -> Unit
}
false
}
is ScrobblerAuthRequiredException -> { is UnsupportedSourceException -> {
val authHelper = scrobblerAuthHelperProvider.get() e.manga?.let { openAlternatives(it) }
if (authHelper.isAuthorized(e.scrobbler)) { false
true }
} else {
host.withContext {
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
}
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( else -> false
e: InteractiveActionRequiredException }
): Boolean = suspendCoroutine { cont -> }.await()
continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e)
}
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont -> private suspend fun resolveBrowserAction(
continuations[CloudFlareActivity.TAG] = cont e: InteractiveActionRequiredException
cloudflareContract.launch(e) ): Boolean = suspendCoroutine { cont ->
} continuations[BrowserActivity.TAG] = cont
browserActionContract.launch(e)
}
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont -> private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
continuations[SourceAuthActivity.TAG] = cont continuations[CloudFlareActivity.TAG] = cont
sourceAuthContract.launch(source) cloudflareContract.launch(e)
} }
private fun openInBrowser(url: String) { private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
host.router()?.openBrowser(url, null, null) continuations[SourceAuthActivity.TAG] = cont
} sourceAuthContract.launch(source)
}
private fun openAlternatives(manga: Manga) { private fun openInBrowser(url: String) {
host.router()?.openAlternatives(manga) host.router.openBrowser(url, null, null)
} }
private fun handleActivityResult(tag: String, result: Boolean) { private fun openAlternatives(manga: Manga) {
continuations.remove(tag)?.resume(result) host.router.openAlternatives(manga)
} }
private fun showSslErrorDialog() { private fun handleActivityResult(tag: String, result: Boolean) {
val ctx = host.getContext() ?: return continuations.remove(tag)?.resume(result)
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 inline fun Host.withContext(block: Context.() -> Unit) { private fun showSslErrorDialog() {
getContext()?.apply(block) 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) { class Factory @Inject constructor(
is FragmentActivity -> router private val settings: AppSettings,
is Fragment -> router private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
else -> null ) {
}
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 val context: Context?
interface Factory {
fun create(host: Host): ExceptionResolver val router: AppRouter
}
companion object { val fragmentManager: FragmentManager
@StringRes inline fun withContext(block: Context.() -> Unit) {
fun getResolveStringId(e: Throwable) = when (e) { context?.apply(block)
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 class ActivityHost(val activity: FragmentActivity) : Host,
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0 ActivityResultCaller by activity,
is SSLException, LifecycleOwner by activity {
is CertPathValidatorException -> R.string.fix
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) get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) } set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
var isReaderDoubleOnFoldable: Boolean
get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false)
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, value) }
@get:FloatRange(0.0, 1.0) @get:FloatRange(0.0, 1.0)
var readerDoublePagesSensitivity: Float var readerDoublePagesSensitivity: Float
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f) get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
@@ -681,7 +685,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages" const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity" const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2"
const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons" const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr" const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted" const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
@@ -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.+$") private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources) 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) { private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
is CaughtException -> cause.getDisplayMessageOrNull(resources) is CaughtException -> cause.getDisplayMessageOrNull(resources)
is WrapperIOException -> cause.getDisplayMessageOrNull(resources) is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
is ScrobblerAuthRequiredException -> resources.getString( is ScrobblerAuthRequiredException -> resources.getString(
R.string.scrobbler_auth_required, R.string.scrobbler_auth_required,
resources.getString(scrobbler.titleResId), resources.getString(scrobbler.titleResId),
) )
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required) is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message) is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException, is ActivityNotFoundException,
is UnsupportedOperationException, is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported) -> resources.getString(R.string.operation_not_supported)
is TooManyRequestExceptions -> { is TooManyRequestExceptions -> {
val delay = getRetryDelay() val delay = getRetryDelay()
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) { val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
resources.formatDurationShort(delay) resources.formatDurationShort(delay)
} else { } else {
null null
} }
if (formattedTime != null) { if (formattedTime != null) {
resources.getString(R.string.too_many_requests_message_retry, formattedTime) resources.getString(R.string.too_many_requests_message_retry, formattedTime)
} else { } else {
resources.getString(R.string.too_many_requests_message) resources.getString(R.string.too_many_requests_message)
} }
} }
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty()) is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
is SQLiteFullException -> resources.getString(R.string.error_no_space_left) is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
is FileNotFoundException -> parseMessage(resources) ?: message is FileNotFoundException -> parseMessage(resources) ?: message
is AccessDeniedException -> resources.getString(R.string.no_access_to_file) is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
is NonFileUriException -> resources.getString(R.string.error_non_file_uri) is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration) is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
is SyncApiException, is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
is ContentUnavailableException -> message is SyncApiException,
is ContentUnavailableException -> message
is ParseException -> shortMessage is ParseException -> shortMessage
is ConnectException, is ConnectException,
is UnknownHostException, is UnknownHostException,
is NoRouteToHostException, is NoRouteToHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error) is SocketTimeoutException -> resources.getString(R.string.network_error)
is ImageDecodeException -> { is ImageDecodeException -> {
val type = format?.substringBefore('/') val type = format?.substringBefore('/')
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) } val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
if (type.isNullOrEmpty() || type == "image") { if (type.isNullOrEmpty() || type == "image") {
resources.getString(R.string.error_image_format, formatString) resources.getString(R.string.error_image_format, formatString)
} else { } else {
resources.getString(R.string.error_not_image, formatString) resources.getString(R.string.error_not_image, formatString)
} }
} }
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received) is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> { is IncompatiblePluginException -> {
cause?.getDisplayMessageOrNull(resources)?.let { cause?.getDisplayMessageOrNull(resources)?.let {
resources.getString(R.string.plugin_incompatible_with_cause, it) resources.getString(R.string.plugin_incompatible_with_cause, it)
} ?: resources.getString(R.string.plugin_incompatible) } ?: resources.getString(R.string.plugin_incompatible)
} }
is WrongPasswordException -> resources.getString(R.string.wrong_password) is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404) is NotFoundException -> resources.getString(R.string.not_found_404)
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source) is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
is HttpException -> getHttpDisplayMessage(response.code, resources) is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
else -> mapDisplayMessage(message, resources) ?: message else -> mapDisplayMessage(message, resources) ?: message
}.takeUnless { it.isNullOrBlank() } }.takeUnless { it.isNullOrBlank() }
@DrawableRes @DrawableRes
fun Throwable.getDisplayIcon(): Int = when (this) { fun Throwable.getDisplayIcon(): Int = when (this) {
is AuthRequiredException -> R.drawable.ic_auth_key_large is AuthRequiredException -> R.drawable.ic_auth_key_large
is CloudFlareProtectedException -> R.drawable.ic_bot_large is CloudFlareProtectedException -> R.drawable.ic_bot_large
is UnknownHostException, is UnknownHostException,
is SocketTimeoutException, is SocketTimeoutException,
is ConnectException, is ConnectException,
is NoRouteToHostException, is NoRouteToHostException,
is ProtocolException -> R.drawable.ic_plug_large 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 is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
else -> R.drawable.ic_error_large else -> R.drawable.ic_error_large
} }
fun Throwable.getCauseUrl(): String? = when (this) { fun Throwable.getCauseUrl(): String? = when (this) {
is ParseException -> url is ParseException -> url
is NotFoundException -> url is NotFoundException -> url
is TooManyRequestExceptions -> url is TooManyRequestExceptions -> url
is CaughtException -> cause.getCauseUrl() is CaughtException -> cause.getCauseUrl()
is WrapperIOException -> cause.getCauseUrl() is WrapperIOException -> cause.getCauseUrl()
is NoDataReceivedException -> url is NoDataReceivedException -> url
is CloudFlareBlockedException -> url is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url is CloudFlareProtectedException -> url
is InteractiveActionRequiredException -> url is InteractiveActionRequiredException -> url
is HttpStatusException -> url is HttpStatusException -> url
is HttpException -> (response.delegate as? Response)?.request?.url?.toString() is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
else -> null 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) { private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404) HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403) HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable) HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
in 500..599 -> resources.getString(R.string.server_error, statusCode) in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> null else -> null
} }
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when { private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) 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.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset) 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_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 == 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 == 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_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) msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
else -> null else -> null
} }
fun Throwable.isReportable(): Boolean { fun Throwable.isReportable(): Boolean {
if (this is Error) { if (this is Error) {
return true return true
} }
if (this is CaughtException) { if (this is CaughtException) {
return cause.isReportable() return cause.isReportable()
} }
if (this is WrapperIOException) { if (this is WrapperIOException) {
return cause.isReportable() return cause.isReportable()
} }
if (ExceptionResolver.canResolve(this)) { if (ExceptionResolver.canResolve(this)) {
return false return false
} }
if (this is ParseException if (this is ParseException
|| this.isNetworkError() || this.isNetworkError()
|| this is CloudFlareBlockedException || this is CloudFlareBlockedException
|| this is CloudFlareProtectedException || this is CloudFlareProtectedException
|| this is BadBackupFormatException || this is BadBackupFormatException
|| this is WrongPasswordException || this is WrongPasswordException
|| this is TooManyRequestExceptions || this is TooManyRequestExceptions
|| this is HttpStatusException || this is HttpStatusException
) { ) {
return false return false
} }
return true return true
} }
fun Throwable.isNetworkError(): Boolean { fun Throwable.isNetworkError(): Boolean {
return this is UnknownHostException return this is UnknownHostException
|| this is SocketTimeoutException || this is SocketTimeoutException
|| this is StreamResetException || this is StreamResetException
|| this is SocketException || this is SocketException
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT || this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
} }
fun Throwable.report(silent: Boolean = false) { fun Throwable.report(silent: Boolean = false) {
val exception = CaughtException(this) val exception = CaughtException(this)
if (!silent) { if (!silent) {
exception.sendWithAcra() exception.sendWithAcra()
} else if (!BuildConfig.DEBUG) { } else if (!BuildConfig.DEBUG) {
exception.sendSilentlyWithAcra() exception.sendSilentlyWithAcra()
} }
} }
fun Throwable.isWebViewUnavailable(): Boolean { fun Throwable.isWebViewUnavailable(): Boolean {
val trace = stackTraceToString() val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>") return trace.contains("android.webkit.WebView.<init>")
} }
@Suppress("FunctionName") @Suppress("FunctionName")
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
fun FileNotFoundException.getFile(): File? { fun FileNotFoundException.getFile(): File? {
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
return groups.getOrNull(1)?.let { File(it) } return groups.getOrNull(1)?.let { File(it) }
} }
fun FileNotFoundException.parseMessage(resources: Resources): String? { fun FileNotFoundException.parseMessage(resources: Resources): String? {
/* /*
Examples: Examples:
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system) /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/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) /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 groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
val path = groups.getOrNull(1) val path = groups.getOrNull(1)
val error = groups.getOrNull(2) val error = groups.getOrNull(2)
val baseMessageIs = when (error) { val baseMessageIs = when (error) {
"EROFS" -> R.string.no_write_permission_to_file "EROFS" -> R.string.no_write_permission_to_file
"ENOENT" -> R.string.file_not_found "ENOENT" -> R.string.file_not_found
else -> return null else -> return null
} }
return if (path.isNullOrEmpty()) { return if (path.isNullOrEmpty()) {
resources.getString(baseMessageIs) resources.getString(baseMessageIs)
} else { } else {
resources.getString( resources.getString(
R.string.inline_preference_pattern, R.string.inline_preference_pattern,
resources.getString(baseMessageIs), resources.getString(baseMessageIs),
path, 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.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.data.filterChapters import org.koitharu.kotatsu.reader.data.filterChapters
import java.util.Locale import java.util.Locale
data class MangaDetails( data class MangaDetails(
private val manga: Manga, private val manga: Manga,
private val localManga: LocalManga?, private val localManga: LocalManga?,
private val override: MangaOverride?, private val override: MangaOverride?,
val description: CharSequence?, val description: CharSequence?,
val isLoaded: Boolean, val isLoaded: Boolean,
) { ) {
constructor(manga: Manga) : this( constructor(manga: Manga) : this(
manga = manga, manga = manga,
localManga = null, localManga = null,
override = null, override = null,
description = null, description = null,
isLoaded = false, isLoaded = false,
) )
val id: Long val id: Long
get() = manga.id 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 { val chapters: Map<String?, List<MangaChapter>> by lazy {
allChapters.groupBy { it.branch } allChapters.groupBy { it.branch }
} }
val isLocal val isLocal
get() = manga.isLocal get() = manga.isLocal
val local: LocalManga? val local: LocalManga?
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
val coverUrl: String? val coverUrl: String?
get() = override?.coverUrl get() = override?.coverUrl
.ifNullOrEmpty { manga.largeCoverUrl } .ifNullOrEmpty { manga.largeCoverUrl }
.ifNullOrEmpty { manga.coverUrl } .ifNullOrEmpty { manga.coverUrl }
.ifNullOrEmpty { localManga?.manga?.coverUrl } .ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty() ?.nullIfEmpty()
private val mergedManga by lazy { val isRestricted: Boolean
if (localManga == null) { get() = manga.state == MangaState.RESTRICTED
// 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 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? { fun toManga() = mergedManga
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
return it
}
return manga.source.getLocale()
}
fun filterChapters(branch: String?) = copy( fun getLocale(): Locale? {
manga = manga.filterChapters(branch), findAppropriateLocale(chapters.keys.singleOrNull())?.let {
localManga = localManga?.run { return it
copy(manga = manga.filterChapters(branch)) }
}, return manga.source.getLocale()
) }
private fun mergeChapters(): List<MangaChapter> { fun filterChapters(branch: String?) = copy(
val chapters = manga.chapters manga = manga.filterChapters(branch),
val localChapters = local?.manga?.chapters.orEmpty() localManga = localManga?.run {
if (chapters.isNullOrEmpty()) { copy(manga = manga.filterChapters(branch))
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? { private fun mergeChapters(): List<MangaChapter> {
if (name.isNullOrEmpty()) { val chapters = manga.chapters
return null val localChapters = local?.manga?.chapters.orEmpty()
} if (chapters.isNullOrEmpty()) {
return Locale.getAvailableLocales().find { lc -> return localChapters
name.contains(lc.getDisplayName(lc), ignoreCase = true) || }
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) || val localMap = if (localChapters.isNotEmpty()) {
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) || localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true) } 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) { override fun onStateChanged(sheet: View, newState: Int) {
val binding = viewBinding ?: return val binding = viewBinding ?: return
binding.layoutTouchBlock.isTouchEventsAllowed = newState != STATE_COLLAPSED binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) { if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
return return
} }

View File

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

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui
import android.app.assist.AssistContent import android.app.assist.AssistContent
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.Gravity import android.view.Gravity
import android.view.KeyEvent import android.view.KeyEvent
@@ -24,6 +25,8 @@ import androidx.transition.Fade
import androidx.transition.Slide import androidx.transition.Slide
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import androidx.transition.TransitionSet import androidx.transition.TransitionSet
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -31,7 +34,9 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -70,483 +75,519 @@ import androidx.appcompat.R as appcompatR
@AndroidEntryPoint @AndroidEntryPoint
class ReaderActivity : class ReaderActivity :
BaseFullscreenActivity<ActivityReaderBinding>(), BaseFullscreenActivity<ActivityReaderBinding>(),
TapGridDispatcher.OnGridTouchListener, TapGridDispatcher.OnGridTouchListener,
ReaderConfigSheet.Callback, ReaderConfigSheet.Callback,
ReaderControlDelegate.OnInteractionListener, ReaderControlDelegate.OnInteractionListener,
ReaderNavigationCallback, ReaderNavigationCallback,
IdlingDetector.Callback, IdlingDetector.Callback,
ZoomControl.ZoomControlListener, ZoomControl.ZoomControlListener,
View.OnClickListener, View.OnClickListener,
ScrollTimerControlView.OnVisibilityChangeListener { ScrollTimerControlView.OnVisibilityChangeListener {
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@Inject @Inject
lateinit var tapGridSettings: TapGridSettings lateinit var tapGridSettings: TapGridSettings
@Inject @Inject
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
@Inject @Inject
lateinit var scrollTimerFactory: ScrollTimer.Factory lateinit var scrollTimerFactory: ScrollTimer.Factory
@Inject @Inject
lateinit var screenOrientationHelper: ScreenOrientationHelper 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? override val readerMode: ReaderMode?
get() = readerManager.currentMode get() = readerManager.currentMode
private lateinit var scrollTimer: ScrollTimer private lateinit var scrollTimer: ScrollTimer
private lateinit var pageSaveHelper: PageSaveHelper private lateinit var pageSaveHelper: PageSaveHelper
private lateinit var touchHelper: TapGridDispatcher private lateinit var touchHelper: TapGridDispatcher
private lateinit var controlDelegate: ReaderControlDelegate private lateinit var controlDelegate: ReaderControlDelegate
private var gestureInsets: Insets = Insets.NONE private var gestureInsets: Insets = Insets.NONE
private lateinit var readerManager: ReaderManager private lateinit var readerManager: ReaderManager
private val hideUiRunnable = Runnable { setUiIsVisible(false) } private val hideUiRunnable = Runnable { setUiIsVisible(false) }
override fun onCreate(savedInstanceState: Bundle?) { // Tracks whether the foldable device is in an unfolded state (half-opened or flat)
super.onCreate(savedInstanceState) private var isFoldUnfolded: Boolean = false
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)
}
}
viewModel.onLoadingError.observeEvent( override fun onCreate(savedInstanceState: Bundle?) {
this, super.onCreate(savedInstanceState)
DialogErrorObserver( setContentView(ActivityReaderBinding.inflate(layoutInflater))
host = viewBinding.container, readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
fragment = null, setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
resolver = exceptionResolver, touchHelper = TapGridDispatcher(viewBinding.root, this)
onResolved = { isResolved -> scrollTimer = scrollTimerFactory.create(resources, this, this)
if (isResolved) { pageSaveHelper = pageSaveHelperFactory.create(this)
viewModel.reload() controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
} else if (viewModel.content.value.pages.isEmpty()) { viewBinding.zoomControl.listener = this
dispatchNavigateUp() viewBinding.actionsView.listener = this
} viewBinding.buttonTimer?.setOnClickListener(this)
}, idlingDetector.bindToLifecycle(this)
), screenOrientationHelper.applySettings()
) viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it }
viewModel.onError.observeEvent( scrollTimer.isActive.observe(this) {
this, updateScrollTimerButton()
SnackbarErrorObserver( viewBinding.actionsView.setTimerActive(it)
host = viewBinding.container, }
fragment = null, viewBinding.timerControl.onVisibilityChangeListener = this
resolver = exceptionResolver, viewBinding.timerControl.attach(scrollTimer, this)
onResolved = null, if (resources.getBoolean(R.bool.is_tablet)) {
), viewBinding.timerControl.updateLayoutParams<CoordinatorLayout.LayoutParams> {
) topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize)
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 getParentActivityIntent(): Intent? { viewModel.onLoadingError.observeEvent(
val manga = viewModel.getMangaOrNull() ?: return null this,
return AppRouter.detailsIntent(this, manga) 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() { observeWindowLayout()
super.onUserInteraction()
if (!viewBinding.timerControl.isVisible) {
scrollTimer.onUserInteraction()
}
idlingDetector.onUserInteraction()
}
override fun onPause() { // Apply initial double-mode considering foldable setting
super.onPause() applyDoubleModeAuto()
viewModel.onPause() }
}
override fun onStop() { override fun getParentActivityIntent(): Intent? {
super.onStop() val manga = viewModel.getMangaOrNull() ?: return null
viewModel.onStop() return AppRouter.detailsIntent(this, manga)
} }
override fun onProvideAssistContent(outContent: AssistContent) { override fun onUserInteraction() {
super.onProvideAssistContent(outContent) super.onUserInteraction()
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it } 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() { override fun onStop() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) super.onStop()
viewModel.onIdle() viewModel.onStop()
} }
override fun onVisibilityChanged(v: View, visibility: Int) { override fun onProvideAssistContent(outContent: AssistContent) {
updateScrollTimerButton() super.onProvideAssistContent(outContent)
} viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
}
override fun onZoomIn() { override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
readerManager.currentReader?.onZoomIn()
}
override fun onZoomOut() { override fun onIdle() {
readerManager.currentReader?.onZoomOut() viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
} viewModel.onIdle()
}
override fun onClick(v: View) { override fun onVisibilityChanged(v: View, visibility: Int) {
when (v.id) { updateScrollTimerButton()
R.id.button_timer -> onScrollTimerClick(isLongClick = false) }
}
}
private fun onInitReader(mode: ReaderMode?) { override fun onZoomIn() {
if (mode == null) { readerManager.currentReader?.onZoomIn()
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)
}
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) { override fun onZoomOut() {
val (isLoading, hasPages) = value readerManager.currentReader?.onZoomOut()
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 onGridTouch(area: TapGridArea): Boolean { override fun onClick(v: View) {
return isReaderResumed() && controlDelegate.onGridTouch(area) when (v.id) {
} R.id.button_timer -> onScrollTimerClick(isLongClick = false)
}
}
override fun onGridLongTouch(area: TapGridArea) { private fun onInitReader(mode: ReaderMode?) {
if (isReaderResumed()) { if (mode == null) {
controlDelegate.onGridLongTouch(area) 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 { private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
return if ( val (isLoading, hasPages) = value
rawX <= gestureInsets.left || val showLoadingLayout = isLoading && !hasPages
rawY <= gestureInsets.top || if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
rawX >= viewBinding.root.width - gestureInsets.right || val transition = Fade().addTarget(viewBinding.layoutLoading)
rawY >= viewBinding.root.height - gestureInsets.bottom || TransitionManager.beginDelayedTransition(viewBinding.root, transition)
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) || viewBinding.layoutLoading.isVisible = showLoadingLayout
viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true }
) { if (isLoading && hasPages) {
false viewBinding.toastView.show(R.string.loading_)
} else { } else {
val touchables = window.peekDecorView()?.touchables viewBinding.toastView.hide()
touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false }
} invalidateOptionsMenu()
} }
override fun dispatchTouchEvent(ev: MotionEvent): Boolean { override fun onGridTouch(area: TapGridArea): Boolean {
touchHelper.dispatchTouchEvent(ev) return isReaderResumed() && controlDelegate.onGridTouch(area)
if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) { }
scrollTimer.onTouchEvent(ev)
}
return super.dispatchTouchEvent(ev)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onGridLongTouch(area: TapGridArea) {
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event) if (isReaderResumed()) {
} controlDelegate.onGridLongTouch(area)
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event) 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 { override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
viewModel.switchChapter(chapter.id, 0) touchHelper.dispatchTouchEvent(ev)
return true if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) {
} scrollTimer.onTouchEvent(ev)
}
return super.dispatchTouchEvent(ev)
}
override fun onPageSelected(page: ReaderPage): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
lifecycleScope.launch(Dispatchers.Default) { return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
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 onReaderModeChanged(mode: ReaderMode) { override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
viewModel.switchMode(mode) }
viewBinding.timerControl.onReaderModeChanged(mode)
}
override fun onDoubleModeChanged(isEnabled: Boolean) { override fun onChapterSelected(chapter: MangaChapter): Boolean {
readerManager.setDoubleReaderMode(isEnabled) viewModel.switchChapter(chapter.id, 0)
} return true
}
private fun setKeepScreenOn(isKeep: Boolean) { override fun onPageSelected(page: ReaderPage): Boolean {
if (isKeep) { lifecycleScope.launch(Dispatchers.Default) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) val pages = viewModel.content.value.pages
} else { val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 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) { override fun onReaderModeChanged(mode: ReaderMode) {
if (viewBinding.appbarTop.isVisible != isUiVisible) { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
if (isAnimationsEnabled) { viewModel.switchMode(mode)
val transition = TransitionSet() viewBinding.timerControl.onReaderModeChanged(mode)
.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 onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { override fun onDoubleModeChanged(isEnabled: Boolean) {
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures()) // Combine manual toggle with foldable auto setting
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) applyDoubleModeAuto(isEnabled)
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 switchPageBy(delta: Int) { private fun applyDoubleModeAuto(manualEnabled: Boolean? = null) {
readerManager.currentReader?.switchPageBy(delta) 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) { private fun setKeepScreenOn(isKeep: Boolean) {
viewModel.switchChapterBy(delta) if (isKeep) {
} window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
override fun openMenu() { private fun setUiIsVisible(isUiVisible: Boolean) {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) if (viewBinding.appbarTop.isVisible != isUiVisible) {
val currentMode = readerManager.currentMode ?: return if (isAnimationsEnabled) {
router.showReaderConfigSheet(currentMode) 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 { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
return readerManager.currentReader?.scrollBy(delta, smooth) == true 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() { override fun switchPageBy(delta: Int) {
setUiIsVisible(!viewBinding.appbarTop.isVisible) readerManager.currentReader?.switchPageBy(delta)
} }
override fun isReaderResumed(): Boolean { override fun switchChapterBy(delta: Int) {
val reader = readerManager.currentReader ?: return false viewModel.switchChapterBy(delta)
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader }
}
override fun onBookmarkClick() { override fun openMenu() {
viewModel.toggleBookmark() viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
} val currentMode = readerManager.currentMode ?: return
router.showReaderConfigSheet(currentMode)
}
override fun onSavePageClick() { override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
viewModel.saveCurrentPage(pageSaveHelper) return readerManager.currentReader?.scrollBy(delta, smooth) == true
} }
override fun onScrollTimerClick(isLongClick: Boolean) { override fun toggleUiVisibility() {
if (isLongClick) { setUiIsVisible(!viewBinding.appbarTop.isVisible)
scrollTimer.setActive(!scrollTimer.isActive.value) }
} else {
viewBinding.timerControl.showOrHide()
}
}
override fun toggleScreenOrientation() { override fun isReaderResumed(): Boolean {
if (screenOrientationHelper.toggleScreenOrientation()) { val reader = readerManager.currentReader ?: return false
Snackbar.make( return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
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 switchPageTo(index: Int) { override fun onBookmarkClick() {
val pages = viewModel.getCurrentChapterPages() viewModel.toggleBookmark()
val page = pages?.getOrNull(index) ?: return }
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
onPageSelected(ReaderPage(page, index, chapterId))
}
private fun onReaderBarChanged(isBarEnabled: Boolean) { override fun onSavePageClick() {
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone viewModel.saveCurrentPage(pageSaveHelper)
} }
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) { override fun onScrollTimerClick(isLongClick: Boolean) {
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair if (isLongClick) {
title = uiState?.mangaName ?: getString(R.string.loading_) scrollTimer.setActive(!scrollTimer.isActive.value)
viewBinding.infoBar.update(uiState) } else {
if (uiState == null) { viewBinding.timerControl.showOrHide()
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() { override fun toggleScreenOrientation() {
val button = viewBinding.buttonTimer ?: return if (screenOrientationHelper.toggleScreenOrientation()) {
val isButtonVisible = scrollTimer.isActive.value Snackbar.make(
&& settings.isReaderAutoscrollFabVisible viewBinding.container,
&& !viewBinding.appbarTop.isVisible if (screenOrientationHelper.isLocked) {
&& !viewBinding.timerControl.isVisible R.string.screen_rotation_locked
if (button.isVisible != isButtonVisible) { } else {
val transition = Fade().addTarget(button) R.string.screen_rotation_unlocked
TransitionManager.beginDelayedTransition(viewBinding.root, transition) },
button.isVisible = isButtonVisible Snackbar.LENGTH_SHORT,
} ).setAnchorView(viewBinding.toolbarDocked)
} .show()
}
}
private fun askForIncognitoMode() { override fun switchPageTo(index: Int) {
buildAlertDialog(this, isCentered = true) { val pages = viewModel.getCurrentChapterPages()
var dontAskAgain = false val page = pages?.getOrNull(index) ?: return
val listener = DialogInterface.OnClickListener { _, which -> val chapterId = viewModel.getCurrentState()?.chapterId ?: return
if (which == DialogInterface.BUTTON_NEUTRAL) { onPageSelected(ReaderPage(page, index, chapterId))
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 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) { fun setDoubleReaderMode(isEnabled: Boolean) {
val mode = currentMode val mode = currentMode
val prevReader = currentReader?.javaClass val prevReader = currentReader?.javaClass
invalidateTypesMap(isEnabled && isLandscape()) invalidateTypesMap(isEnabled)
val newReader = modeMap[mode] val newReader = modeMap[mode]
if (mode != null && newReader != prevReader) { if (mode != null && newReader != prevReader) {
replace(mode) replace(mode)

View File

@@ -38,228 +38,244 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ReaderConfigSheet : class ReaderConfigSheet :
BaseAdaptiveSheet<SheetReaderConfigBinding>(), BaseAdaptiveSheet<SheetReaderConfigBinding>(),
View.OnClickListener, View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener, MaterialButtonToggleGroup.OnButtonCheckedListener,
CompoundButton.OnCheckedChangeListener, CompoundButton.OnCheckedChangeListener,
Slider.OnChangeListener { Slider.OnChangeListener {
private val viewModel by activityViewModels<ReaderViewModel>() private val viewModel by activityViewModels<ReaderViewModel>()
@Inject @Inject
lateinit var orientationHelper: ScreenOrientationHelper lateinit var orientationHelper: ScreenOrientationHelper
@Inject @Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory lateinit var mangaRepositoryFactory: MangaRepository.Factory
@Inject @Inject
lateinit var pageLoader: PageLoader lateinit var pageLoader: PageLoader
private lateinit var mode: ReaderMode private lateinit var mode: ReaderMode
private lateinit var imageServerDelegate: ImageServerDelegate private lateinit var imageServerDelegate: ImageServerDelegate
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
mode = arguments?.getInt(AppRouter.KEY_READER_MODE) mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
?.let { ReaderMode.valueOf(it) } ?.let { ReaderMode.valueOf(it) }
?: ReaderMode.STANDARD ?: ReaderMode.STANDARD
imageServerDelegate = ImageServerDelegate( imageServerDelegate = ImageServerDelegate(
mangaRepositoryFactory = mangaRepositoryFactory, mangaRepositoryFactory = mangaRepositoryFactory,
mangaSource = viewModel.getMangaOrNull()?.source, 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
} }
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() readerAdapter = onCreateAdapter()
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) { // Determine which state to use for restoring position:
onPagesChanged(it.pages, viewModel.getCurrentState()) // - content.state: explicitly set state (e.g., after mode switch or chapter change)
} else { // - getCurrentState(): current reading position saved in SavedStateHandle
onPagesChanged(it.pages, it.state) val currentState = viewModel.getCurrentState()
val pendingState = when {
// If content.state is null and we have pages, use getCurrentState
it.state == null
&& it.pages.isNotEmpty()
&& readerAdapter?.hasItems != true -> currentState
// use currentState only if it matches the current pages (to avoid the error message)
readerAdapter?.hasItems != true
&& it.state != currentState
&& currentState != null
&& it.pages.any { page -> page.chapterId == currentState.chapterId } -> currentState
// Otherwise, use content.state (normal flow, mode switch, chapter change)
else -> it.state
} }
onPagesChanged(it.pages, pendingState)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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