Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f6e5232b | ||
|
|
f205c1b3dc | ||
|
|
4b2a487c37 | ||
|
|
726ac21974 | ||
|
|
6b35216949 | ||
|
|
22cae62f17 | ||
|
|
4733caf2e6 | ||
|
|
d49103de1f | ||
|
|
414bab7ce3 | ||
|
|
64c1873eb5 | ||
|
|
06a0b5829b | ||
|
|
0ce2870c8b | ||
|
|
f59027666b | ||
|
|
8513bc6daf | ||
|
|
cceaefc896 | ||
|
|
1d32f53bdd | ||
|
|
0e98dd8695 | ||
|
|
119b7c2ac7 | ||
|
|
5701862661 | ||
|
|
5590ab7c8a | ||
|
|
9fde0106be | ||
|
|
e73f077dc5 | ||
|
|
c37458d43a | ||
|
|
e2fcfcc7a8 | ||
|
|
7a3b2a9bb4 |
40
README.md
40
README.md
@@ -1,24 +1,16 @@
|
|||||||
|
> [!IMPORTANT]
|
||||||
|
> In light of recent challenges — including threating actions from Kakao Entertainment Corp and upcoming Google’s
|
||||||
|
> [new sideloading policy](https://f-droid.org/ru/2025/10/28/sideloading.html) — we’ve made the difficult decision to shut down Kotatsu and end its support. We’re 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)
|
 [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](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.**
|
|
||||||
|
|
||||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user