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">
|
||||
|
||||
<a href="https://kotatsu.app">
|
||||
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
|
||||
</a>
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in
|
||||
online content sources.**
|
||||
|
||||
# [Kotatsu](https://kotatsu.app)
|
||||
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||
|
||||
   [](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>
|
||||
 [](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)
|
||||
|
||||
### Main Features
|
||||
|
||||
@@ -86,7 +78,8 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
|
||||
|
||||
</br>
|
||||
|
||||
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||
**📌 Pull requests are welcome, if you want:
|
||||
See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||
|
||||
### Certificate fingerprints
|
||||
|
||||
@@ -104,7 +97,9 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
|
||||
|
||||
<div align="left">
|
||||
|
||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
||||
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
||||
install instructions.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -112,6 +107,9 @@ You may copy, distribute and modify the software as long as you track changes/da
|
||||
|
||||
<div align="left">
|
||||
|
||||
The developers of this application do not have any affiliation with the content available in the app and does not store or distribute any content. This application should be considered a web browser, all content that can be found using this application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website where the content is hosted.
|
||||
The developers of this application do not have any affiliation with the content available in the app and does not store
|
||||
or distribute any content. This application should be considered a web browser, all content that can be found using this
|
||||
application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website
|
||||
where the content is hosted.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 1032
|
||||
versionName = '9.4'
|
||||
versionCode = 1033
|
||||
versionName = '9.4.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -155,6 +155,9 @@ dependencies {
|
||||
implementation libs.androidx.work.runtime
|
||||
implementation libs.guava
|
||||
|
||||
// Foldable/Window layout
|
||||
implementation libs.androidx.window
|
||||
|
||||
implementation libs.androidx.room.runtime
|
||||
implementation libs.androidx.room.ktx
|
||||
ksp libs.androidx.room.compiler
|
||||
|
||||
@@ -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.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.async
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
@@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -32,164 +35,221 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class ExceptionResolver @AssistedInject constructor(
|
||||
@Assisted private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
class ExceptionResolver private constructor(
|
||||
private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
|
||||
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
|
||||
handleActivityResult(BrowserActivity.TAG, true)
|
||||
}
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
|
||||
handleActivityResult(BrowserActivity.TAG, true)
|
||||
}
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router()?.showErrorDialog(e, url)
|
||||
}
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router.showErrorDialog(e, url)
|
||||
}
|
||||
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
|
||||
when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
|
||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||
|
||||
is ProxyConfigException -> {
|
||||
host.router()?.openProxySettings()
|
||||
false
|
||||
}
|
||||
is ProxyConfigException -> {
|
||||
host.router.openProxySettings()
|
||||
false
|
||||
}
|
||||
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
}
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
}
|
||||
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
is EmptyMangaException -> {
|
||||
when (e.reason) {
|
||||
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
|
||||
EmptyMangaReason.LOADING_ERROR -> Unit
|
||||
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
|
||||
else -> Unit
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
is ScrobblerAuthRequiredException -> {
|
||||
val authHelper = scrobblerAuthHelperProvider.get()
|
||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
is ScrobblerAuthRequiredException -> {
|
||||
val authHelper = scrobblerAuthHelperProvider.get()
|
||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveBrowserAction(
|
||||
e: InteractiveActionRequiredException
|
||||
): Boolean = suspendCoroutine { cont ->
|
||||
continuations[BrowserActivity.TAG] = cont
|
||||
browserActionContract.launch(e)
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}.await()
|
||||
|
||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||
continuations[CloudFlareActivity.TAG] = cont
|
||||
cloudflareContract.launch(e)
|
||||
}
|
||||
private suspend fun resolveBrowserAction(
|
||||
e: InteractiveActionRequiredException
|
||||
): Boolean = suspendCoroutine { cont ->
|
||||
continuations[BrowserActivity.TAG] = cont
|
||||
browserActionContract.launch(e)
|
||||
}
|
||||
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
continuations[SourceAuthActivity.TAG] = cont
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||
continuations[CloudFlareActivity.TAG] = cont
|
||||
cloudflareContract.launch(e)
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router()?.openBrowser(url, null, null)
|
||||
}
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
continuations[SourceAuthActivity.TAG] = cont
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router()?.openAlternatives(manga)
|
||||
}
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router.openBrowser(url, null, null)
|
||||
}
|
||||
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router.openAlternatives(manga)
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = host.getContext() ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
|
||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||
getContext()?.apply(block)
|
||||
}
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = host.context ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun Host.router(): AppRouter? = when (this) {
|
||||
is FragmentActivity -> router
|
||||
is Fragment -> router
|
||||
else -> null
|
||||
}
|
||||
class Factory @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
|
||||
interface Host : ActivityResultCaller {
|
||||
fun create(fragment: Fragment) = ExceptionResolver(
|
||||
host = Host.FragmentHost(fragment),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
|
||||
fun getChildFragmentManager(): FragmentManager
|
||||
fun create(activity: FragmentActivity) = ExceptionResolver(
|
||||
host = Host.ActivityHost(activity),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
}
|
||||
|
||||
fun getContext(): Context?
|
||||
}
|
||||
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
val context: Context?
|
||||
|
||||
fun create(host: Host): ExceptionResolver
|
||||
}
|
||||
val router: AppRouter
|
||||
|
||||
companion object {
|
||||
val fragmentManager: FragmentManager
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
inline fun withContext(block: Context.() -> Unit) {
|
||||
context?.apply(block)
|
||||
}
|
||||
|
||||
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
class ActivityHost(val activity: FragmentActivity) : Host,
|
||||
ActivityResultCaller by activity,
|
||||
LifecycleOwner by activity {
|
||||
|
||||
is ProxyConfigException -> R.string.settings
|
||||
override val context: Context
|
||||
get() = activity
|
||||
|
||||
is InteractiveActionRequiredException -> R.string._continue
|
||||
override val router: AppRouter
|
||||
get() = activity.router
|
||||
|
||||
else -> 0
|
||||
}
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = activity.supportFragmentManager
|
||||
}
|
||||
|
||||
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
|
||||
}
|
||||
class FragmentHost(val fragment: Fragment) : Host,
|
||||
ActivityResultCaller by fragment {
|
||||
|
||||
override val context: Context?
|
||||
get() = fragment.context
|
||||
|
||||
override val router: AppRouter
|
||||
get() = fragment.router
|
||||
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = fragment.childFragmentManager
|
||||
|
||||
override val lifecycle: Lifecycle
|
||||
get() = fragment.viewLifecycleOwner.lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
|
||||
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
is ProxyConfigException -> R.string.settings
|
||||
|
||||
is InteractiveActionRequiredException -> R.string._continue
|
||||
|
||||
is EmptyMangaException -> when (e.reason) {
|
||||
EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0
|
||||
EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives
|
||||
else -> 0
|
||||
}
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
||||
|
||||
var isReaderDoubleOnFoldable: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, value) }
|
||||
|
||||
@get:FloatRange(0.0, 1.0)
|
||||
var readerDoublePagesSensitivity: Float
|
||||
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
|
||||
@@ -681,7 +685,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
||||
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity"
|
||||
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2"
|
||||
const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable"
|
||||
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
||||
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ExceptionResolver.Host,
|
||||
OnApplyWindowInsetsListener,
|
||||
ScreenshotPolicyHelper.ContentContainer {
|
||||
|
||||
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(view: View?) = throw UnsupportedOperationException()
|
||||
|
||||
override fun getContext() = this
|
||||
|
||||
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
|
||||
|
||||
protected fun setContentView(binding: B) {
|
||||
this.viewBinding = binding
|
||||
super.setContentView(binding.root)
|
||||
|
||||
@@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
OnApplyWindowInsetsListener,
|
||||
Fragment(),
|
||||
ExceptionResolver.Host {
|
||||
Fragment() {
|
||||
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
|
||||
@@ -36,8 +36,7 @@ import com.google.android.material.R as materialR
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
OnApplyWindowInsetsListener,
|
||||
RecyclerViewOwner,
|
||||
ExceptionResolver.Host {
|
||||
RecyclerViewOwner {
|
||||
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
|
||||
@@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
|
||||
OnApplyWindowInsetsListener,
|
||||
ExceptionResolver.Host {
|
||||
OnApplyWindowInsetsListener {
|
||||
|
||||
private var waitingForDismissAllowingStateLoss = false
|
||||
private var isFitToContentsDisabled = false
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||
@@ -62,216 +63,219 @@ private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
||||
private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
|
||||
|
||||
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
|
||||
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
|
||||
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
R.string.scrobbler_auth_required,
|
||||
resources.getString(scrobbler.titleResId),
|
||||
)
|
||||
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
R.string.scrobbler_auth_required,
|
||||
resources.getString(scrobbler.titleResId),
|
||||
)
|
||||
|
||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
|
||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
|
||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||
is ActivityNotFoundException,
|
||||
is UnsupportedOperationException,
|
||||
-> resources.getString(R.string.operation_not_supported)
|
||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
|
||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
|
||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||
is ActivityNotFoundException,
|
||||
is UnsupportedOperationException,
|
||||
-> resources.getString(R.string.operation_not_supported)
|
||||
|
||||
is TooManyRequestExceptions -> {
|
||||
val delay = getRetryDelay()
|
||||
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||
resources.formatDurationShort(delay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (formattedTime != null) {
|
||||
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||
} else {
|
||||
resources.getString(R.string.too_many_requests_message)
|
||||
}
|
||||
}
|
||||
is TooManyRequestExceptions -> {
|
||||
val delay = getRetryDelay()
|
||||
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||
resources.formatDurationShort(delay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (formattedTime != null) {
|
||||
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||
} else {
|
||||
resources.getString(R.string.too_many_requests_message)
|
||||
}
|
||||
}
|
||||
|
||||
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
||||
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException -> message
|
||||
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
||||
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException -> message
|
||||
|
||||
is ParseException -> shortMessage
|
||||
is ConnectException,
|
||||
is UnknownHostException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
is ParseException -> shortMessage
|
||||
is ConnectException,
|
||||
is UnknownHostException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
|
||||
is ImageDecodeException -> {
|
||||
val type = format?.substringBefore('/')
|
||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||
if (type.isNullOrEmpty() || type == "image") {
|
||||
resources.getString(R.string.error_image_format, formatString)
|
||||
} else {
|
||||
resources.getString(R.string.error_not_image, formatString)
|
||||
}
|
||||
}
|
||||
is ImageDecodeException -> {
|
||||
val type = format?.substringBefore('/')
|
||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||
if (type.isNullOrEmpty() || type == "image") {
|
||||
resources.getString(R.string.error_image_format, formatString)
|
||||
} else {
|
||||
resources.getString(R.string.error_not_image, formatString)
|
||||
}
|
||||
}
|
||||
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> {
|
||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||
} ?: resources.getString(R.string.plugin_incompatible)
|
||||
}
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> {
|
||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||
} ?: resources.getString(R.string.plugin_incompatible)
|
||||
}
|
||||
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
|
||||
else -> mapDisplayMessage(message, resources) ?: message
|
||||
else -> mapDisplayMessage(message, resources) ?: message
|
||||
}.takeUnless { it.isNullOrBlank() }
|
||||
|
||||
@DrawableRes
|
||||
fun Throwable.getDisplayIcon(): Int = when (this) {
|
||||
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is ProtocolException -> R.drawable.ic_plug_large
|
||||
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is ProtocolException -> R.drawable.ic_plug_large
|
||||
|
||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||
|
||||
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
|
||||
else -> R.drawable.ic_error_large
|
||||
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
|
||||
else -> R.drawable.ic_error_large
|
||||
}
|
||||
|
||||
fun Throwable.getCauseUrl(): String? = when (this) {
|
||||
is ParseException -> url
|
||||
is NotFoundException -> url
|
||||
is TooManyRequestExceptions -> url
|
||||
is CaughtException -> cause.getCauseUrl()
|
||||
is WrapperIOException -> cause.getCauseUrl()
|
||||
is NoDataReceivedException -> url
|
||||
is CloudFlareBlockedException -> url
|
||||
is CloudFlareProtectedException -> url
|
||||
is InteractiveActionRequiredException -> url
|
||||
is HttpStatusException -> url
|
||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
||||
else -> null
|
||||
is ParseException -> url
|
||||
is NotFoundException -> url
|
||||
is TooManyRequestExceptions -> url
|
||||
is CaughtException -> cause.getCauseUrl()
|
||||
is WrapperIOException -> cause.getCauseUrl()
|
||||
is NoDataReceivedException -> url
|
||||
is CloudFlareBlockedException -> url
|
||||
is CloudFlareProtectedException -> url
|
||||
is InteractiveActionRequiredException -> url
|
||||
is HttpStatusException -> url
|
||||
is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
|
||||
is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() }
|
||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
|
||||
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
||||
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
||||
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
|
||||
msg.isNullOrEmpty() -> null
|
||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
||||
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
|
||||
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
||||
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
||||
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
||||
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
|
||||
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||
else -> null
|
||||
msg.isNullOrEmpty() -> null
|
||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
||||
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
|
||||
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
||||
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
||||
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
||||
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
|
||||
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun Throwable.isReportable(): Boolean {
|
||||
if (this is Error) {
|
||||
return true
|
||||
}
|
||||
if (this is CaughtException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (this is WrapperIOException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (ExceptionResolver.canResolve(this)) {
|
||||
return false
|
||||
}
|
||||
if (this is ParseException
|
||||
|| this.isNetworkError()
|
||||
|| this is CloudFlareBlockedException
|
||||
|| this is CloudFlareProtectedException
|
||||
|| this is BadBackupFormatException
|
||||
|| this is WrongPasswordException
|
||||
|| this is TooManyRequestExceptions
|
||||
|| this is HttpStatusException
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
if (this is Error) {
|
||||
return true
|
||||
}
|
||||
if (this is CaughtException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (this is WrapperIOException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (ExceptionResolver.canResolve(this)) {
|
||||
return false
|
||||
}
|
||||
if (this is ParseException
|
||||
|| this.isNetworkError()
|
||||
|| this is CloudFlareBlockedException
|
||||
|| this is CloudFlareProtectedException
|
||||
|| this is BadBackupFormatException
|
||||
|| this is WrongPasswordException
|
||||
|| this is TooManyRequestExceptions
|
||||
|| this is HttpStatusException
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun Throwable.isNetworkError(): Boolean {
|
||||
return this is UnknownHostException
|
||||
|| this is SocketTimeoutException
|
||||
|| this is StreamResetException
|
||||
|| this is SocketException
|
||||
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
||||
return this is UnknownHostException
|
||||
|| this is SocketTimeoutException
|
||||
|| this is StreamResetException
|
||||
|| this is SocketException
|
||||
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
||||
}
|
||||
|
||||
fun Throwable.report(silent: Boolean = false) {
|
||||
val exception = CaughtException(this)
|
||||
if (!silent) {
|
||||
exception.sendWithAcra()
|
||||
} else if (!BuildConfig.DEBUG) {
|
||||
exception.sendSilentlyWithAcra()
|
||||
}
|
||||
val exception = CaughtException(this)
|
||||
if (!silent) {
|
||||
exception.sendWithAcra()
|
||||
} else if (!BuildConfig.DEBUG) {
|
||||
exception.sendSilentlyWithAcra()
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.isWebViewUnavailable(): Boolean {
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
|
||||
|
||||
fun FileNotFoundException.getFile(): File? {
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
return groups.getOrNull(1)?.let { File(it) }
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
return groups.getOrNull(1)?.let { File(it) }
|
||||
}
|
||||
|
||||
fun FileNotFoundException.parseMessage(resources: Resources): String? {
|
||||
/*
|
||||
Examples:
|
||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
||||
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
|
||||
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
|
||||
*/
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
val path = groups.getOrNull(1)
|
||||
val error = groups.getOrNull(2)
|
||||
val baseMessageIs = when (error) {
|
||||
"EROFS" -> R.string.no_write_permission_to_file
|
||||
"ENOENT" -> R.string.file_not_found
|
||||
else -> return null
|
||||
}
|
||||
return if (path.isNullOrEmpty()) {
|
||||
resources.getString(baseMessageIs)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.inline_preference_pattern,
|
||||
resources.getString(baseMessageIs),
|
||||
path,
|
||||
)
|
||||
}
|
||||
/*
|
||||
Examples:
|
||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
||||
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
|
||||
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
|
||||
*/
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
val path = groups.getOrNull(1)
|
||||
val error = groups.getOrNull(2)
|
||||
val baseMessageIs = when (error) {
|
||||
"EROFS" -> R.string.no_write_permission_to_file
|
||||
"ENOENT" -> R.string.file_not_found
|
||||
else -> return null
|
||||
}
|
||||
return if (path.isNullOrEmpty()) {
|
||||
resources.getString(baseMessageIs)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.inline_preference_pattern,
|
||||
resources.getString(baseMessageIs),
|
||||
path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,111 +7,115 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||
import java.util.Locale
|
||||
|
||||
data class MangaDetails(
|
||||
private val manga: Manga,
|
||||
private val localManga: LocalManga?,
|
||||
private val override: MangaOverride?,
|
||||
val description: CharSequence?,
|
||||
val isLoaded: Boolean,
|
||||
private val manga: Manga,
|
||||
private val localManga: LocalManga?,
|
||||
private val override: MangaOverride?,
|
||||
val description: CharSequence?,
|
||||
val isLoaded: Boolean,
|
||||
) {
|
||||
|
||||
constructor(manga: Manga) : this(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = null,
|
||||
description = null,
|
||||
isLoaded = false,
|
||||
)
|
||||
constructor(manga: Manga) : this(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = null,
|
||||
description = null,
|
||||
isLoaded = false,
|
||||
)
|
||||
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
|
||||
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||
allChapters.groupBy { it.branch }
|
||||
}
|
||||
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||
allChapters.groupBy { it.branch }
|
||||
}
|
||||
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
|
||||
val local: LocalManga?
|
||||
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
||||
val local: LocalManga?
|
||||
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
||||
|
||||
val coverUrl: String?
|
||||
get() = override?.coverUrl
|
||||
.ifNullOrEmpty { manga.largeCoverUrl }
|
||||
.ifNullOrEmpty { manga.coverUrl }
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
val coverUrl: String?
|
||||
get() = override?.coverUrl
|
||||
.ifNullOrEmpty { manga.largeCoverUrl }
|
||||
.ifNullOrEmpty { manga.coverUrl }
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
manga.withOverride(override)
|
||||
} else {
|
||||
manga.copy(
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
contentRating = override?.contentRating ?: manga.contentRating,
|
||||
chapters = allChapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
val isRestricted: Boolean
|
||||
get() = manga.state == MangaState.RESTRICTED
|
||||
|
||||
fun toManga() = mergedManga
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
manga.withOverride(override)
|
||||
} else {
|
||||
manga.copy(
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
contentRating = override?.contentRating ?: manga.contentRating,
|
||||
chapters = allChapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
return it
|
||||
}
|
||||
return manga.source.getLocale()
|
||||
}
|
||||
fun toManga() = mergedManga
|
||||
|
||||
fun filterChapters(branch: String?) = copy(
|
||||
manga = manga.filterChapters(branch),
|
||||
localManga = localManga?.run {
|
||||
copy(manga = manga.filterChapters(branch))
|
||||
},
|
||||
)
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
return it
|
||||
}
|
||||
return manga.source.getLocale()
|
||||
}
|
||||
|
||||
private fun mergeChapters(): List<MangaChapter> {
|
||||
val chapters = manga.chapters
|
||||
val localChapters = local?.manga?.chapters.orEmpty()
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return localChapters
|
||||
}
|
||||
val localMap = if (localChapters.isNotEmpty()) {
|
||||
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = ArrayList<MangaChapter>(chapters.size)
|
||||
for (chapter in chapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
result += local ?: chapter
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
result.addAll(localMap.values)
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun filterChapters(branch: String?) = copy(
|
||||
manga = manga.filterChapters(branch),
|
||||
localManga = localManga?.run {
|
||||
copy(manga = manga.filterChapters(branch))
|
||||
},
|
||||
)
|
||||
|
||||
private fun findAppropriateLocale(name: String?): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
private fun mergeChapters(): List<MangaChapter> {
|
||||
val chapters = manga.chapters
|
||||
val localChapters = local?.manga?.chapters.orEmpty()
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return localChapters
|
||||
}
|
||||
val localMap = if (localChapters.isNotEmpty()) {
|
||||
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = ArrayList<MangaChapter>(chapters.size)
|
||||
for (chapter in chapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
result += local ?: chapter
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
result.addAll(localMap.values)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun findAppropriateLocale(name: String?): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
|
||||
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
val binding = viewBinding ?: return
|
||||
binding.layoutTouchBlock.isTouchEventsAllowed = newState != STATE_COLLAPSED
|
||||
binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
|
||||
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -133,9 +133,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
||||
layoutBody.updatePadding(top = layoutBody.paddingBottom)
|
||||
scrollView.scrollIndicators = 0
|
||||
buttonDone.isVisible = false
|
||||
this.root.updateLayoutParams {
|
||||
height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
this.root.layoutParams?.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
buttonSave.updateLayoutParams<LinearLayout.LayoutParams> {
|
||||
weight = 0f
|
||||
width = LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
@@ -24,6 +25,8 @@ import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import androidx.transition.TransitionManager
|
||||
import androidx.transition.TransitionSet
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import androidx.window.layout.WindowInfoTracker
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -31,7 +34,9 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -70,483 +75,519 @@ import androidx.appcompat.R as appcompatR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReaderActivity :
|
||||
BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
TapGridDispatcher.OnGridTouchListener,
|
||||
ReaderConfigSheet.Callback,
|
||||
ReaderControlDelegate.OnInteractionListener,
|
||||
ReaderNavigationCallback,
|
||||
IdlingDetector.Callback,
|
||||
ZoomControl.ZoomControlListener,
|
||||
View.OnClickListener,
|
||||
ScrollTimerControlView.OnVisibilityChangeListener {
|
||||
BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
TapGridDispatcher.OnGridTouchListener,
|
||||
ReaderConfigSheet.Callback,
|
||||
ReaderControlDelegate.OnInteractionListener,
|
||||
ReaderNavigationCallback,
|
||||
IdlingDetector.Callback,
|
||||
ZoomControl.ZoomControlListener,
|
||||
View.OnClickListener,
|
||||
ScrollTimerControlView.OnVisibilityChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
@Inject
|
||||
lateinit var tapGridSettings: TapGridSettings
|
||||
@Inject
|
||||
lateinit var tapGridSettings: TapGridSettings
|
||||
|
||||
@Inject
|
||||
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
|
||||
@Inject
|
||||
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var scrollTimerFactory: ScrollTimer.Factory
|
||||
@Inject
|
||||
lateinit var scrollTimerFactory: ScrollTimer.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var screenOrientationHelper: ScreenOrientationHelper
|
||||
@Inject
|
||||
lateinit var screenOrientationHelper: ScreenOrientationHelper
|
||||
|
||||
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
|
||||
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
|
||||
|
||||
private val viewModel: ReaderViewModel by viewModels()
|
||||
private val viewModel: ReaderViewModel by viewModels()
|
||||
|
||||
override val readerMode: ReaderMode?
|
||||
get() = readerManager.currentMode
|
||||
override val readerMode: ReaderMode?
|
||||
get() = readerManager.currentMode
|
||||
|
||||
private lateinit var scrollTimer: ScrollTimer
|
||||
private lateinit var pageSaveHelper: PageSaveHelper
|
||||
private lateinit var touchHelper: TapGridDispatcher
|
||||
private lateinit var controlDelegate: ReaderControlDelegate
|
||||
private var gestureInsets: Insets = Insets.NONE
|
||||
private lateinit var readerManager: ReaderManager
|
||||
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
|
||||
private lateinit var scrollTimer: ScrollTimer
|
||||
private lateinit var pageSaveHelper: PageSaveHelper
|
||||
private lateinit var touchHelper: TapGridDispatcher
|
||||
private lateinit var controlDelegate: ReaderControlDelegate
|
||||
private var gestureInsets: Insets = Insets.NONE
|
||||
private lateinit var readerManager: ReaderManager
|
||||
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
||||
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
touchHelper = TapGridDispatcher(viewBinding.root, this)
|
||||
scrollTimer = scrollTimerFactory.create(resources, this, this)
|
||||
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||
controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
|
||||
viewBinding.zoomControl.listener = this
|
||||
viewBinding.actionsView.listener = this
|
||||
viewBinding.buttonTimer?.setOnClickListener(this)
|
||||
idlingDetector.bindToLifecycle(this)
|
||||
screenOrientationHelper.applySettings()
|
||||
viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it }
|
||||
scrollTimer.isActive.observe(this) {
|
||||
updateScrollTimerButton()
|
||||
viewBinding.actionsView.setTimerActive(it)
|
||||
}
|
||||
viewBinding.timerControl.onVisibilityChangeListener = this
|
||||
viewBinding.timerControl.attach(scrollTimer, this)
|
||||
if (resources.getBoolean(R.bool.is_tablet)) {
|
||||
viewBinding.timerControl.updateLayoutParams<CoordinatorLayout.LayoutParams> {
|
||||
topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize)
|
||||
}
|
||||
}
|
||||
// Tracks whether the foldable device is in an unfolded state (half-opened or flat)
|
||||
private var isFoldUnfolded: Boolean = false
|
||||
|
||||
viewModel.onLoadingError.observeEvent(
|
||||
this,
|
||||
DialogErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = { isResolved ->
|
||||
if (isResolved) {
|
||||
viewModel.reload()
|
||||
} else if (viewModel.content.value.pages.isEmpty()) {
|
||||
dispatchNavigateUp()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
viewModel.onError.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = null,
|
||||
),
|
||||
)
|
||||
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
|
||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
|
||||
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
|
||||
combine(
|
||||
viewModel.isLoading,
|
||||
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
|
||||
::Pair,
|
||||
).flowOn(Dispatchers.Default)
|
||||
.observe(this, this::onLoadingStateChanged)
|
||||
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
|
||||
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
|
||||
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
|
||||
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
|
||||
viewModel.onAskNsfwIncognito.observeEvent(this) { askForIncognitoMode() }
|
||||
viewModel.onShowToast.observeEvent(this) { msgId ->
|
||||
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
|
||||
.setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
viewModel.readerSettingsProducer.observe(this) {
|
||||
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
|
||||
}
|
||||
viewModel.isZoomControlsEnabled.observe(this) {
|
||||
viewBinding.zoomControl.isVisible = it
|
||||
}
|
||||
addMenuProvider(ReaderMenuProvider(viewModel))
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
||||
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
touchHelper = TapGridDispatcher(viewBinding.root, this)
|
||||
scrollTimer = scrollTimerFactory.create(resources, this, this)
|
||||
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||
controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this)
|
||||
viewBinding.zoomControl.listener = this
|
||||
viewBinding.actionsView.listener = this
|
||||
viewBinding.buttonTimer?.setOnClickListener(this)
|
||||
idlingDetector.bindToLifecycle(this)
|
||||
screenOrientationHelper.applySettings()
|
||||
viewModel.isBookmarkAdded.observe(this) { viewBinding.actionsView.isBookmarkAdded = it }
|
||||
scrollTimer.isActive.observe(this) {
|
||||
updateScrollTimerButton()
|
||||
viewBinding.actionsView.setTimerActive(it)
|
||||
}
|
||||
viewBinding.timerControl.onVisibilityChangeListener = this
|
||||
viewBinding.timerControl.attach(scrollTimer, this)
|
||||
if (resources.getBoolean(R.bool.is_tablet)) {
|
||||
viewBinding.timerControl.updateLayoutParams<CoordinatorLayout.LayoutParams> {
|
||||
topMargin = marginEnd + getThemeDimensionPixelOffset(appcompatR.attr.actionBarSize)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getParentActivityIntent(): Intent? {
|
||||
val manga = viewModel.getMangaOrNull() ?: return null
|
||||
return AppRouter.detailsIntent(this, manga)
|
||||
}
|
||||
viewModel.onLoadingError.observeEvent(
|
||||
this,
|
||||
DialogErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = { isResolved ->
|
||||
if (isResolved) {
|
||||
viewModel.reload()
|
||||
} else if (viewModel.content.value.pages.isEmpty()) {
|
||||
dispatchNavigateUp()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
viewModel.onError.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = null,
|
||||
),
|
||||
)
|
||||
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
|
||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
|
||||
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
|
||||
combine(
|
||||
viewModel.isLoading,
|
||||
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
|
||||
::Pair,
|
||||
).flowOn(Dispatchers.Default)
|
||||
.observe(this, this::onLoadingStateChanged)
|
||||
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
|
||||
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
|
||||
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
|
||||
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this))
|
||||
viewModel.onAskNsfwIncognito.observeEvent(this) { askForIncognitoMode() }
|
||||
viewModel.onShowToast.observeEvent(this) { msgId ->
|
||||
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
|
||||
.setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
viewModel.readerSettingsProducer.observe(this) {
|
||||
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
|
||||
}
|
||||
viewModel.isZoomControlsEnabled.observe(this) {
|
||||
viewBinding.zoomControl.isVisible = it
|
||||
}
|
||||
addMenuProvider(ReaderMenuProvider(viewModel))
|
||||
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
if (!viewBinding.timerControl.isVisible) {
|
||||
scrollTimer.onUserInteraction()
|
||||
}
|
||||
idlingDetector.onUserInteraction()
|
||||
}
|
||||
observeWindowLayout()
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
viewModel.onPause()
|
||||
}
|
||||
// Apply initial double-mode considering foldable setting
|
||||
applyDoubleModeAuto()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
viewModel.onStop()
|
||||
}
|
||||
override fun getParentActivityIntent(): Intent? {
|
||||
val manga = viewModel.getMangaOrNull() ?: return null
|
||||
return AppRouter.detailsIntent(this, manga)
|
||||
}
|
||||
|
||||
override fun onProvideAssistContent(outContent: AssistContent) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
if (!viewBinding.timerControl.isVisible) {
|
||||
scrollTimer.onUserInteraction()
|
||||
}
|
||||
idlingDetector.onUserInteraction()
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
viewModel.onPause()
|
||||
}
|
||||
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.onIdle()
|
||||
}
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
viewModel.onStop()
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(v: View, visibility: Int) {
|
||||
updateScrollTimerButton()
|
||||
}
|
||||
override fun onProvideAssistContent(outContent: AssistContent) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
|
||||
override fun onZoomIn() {
|
||||
readerManager.currentReader?.onZoomIn()
|
||||
}
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.isMangaNsfw
|
||||
|
||||
override fun onZoomOut() {
|
||||
readerManager.currentReader?.onZoomOut()
|
||||
}
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.onIdle()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_timer -> onScrollTimerClick(isLongClick = false)
|
||||
}
|
||||
}
|
||||
override fun onVisibilityChanged(v: View, visibility: Int) {
|
||||
updateScrollTimerButton()
|
||||
}
|
||||
|
||||
private fun onInitReader(mode: ReaderMode?) {
|
||||
if (mode == null) {
|
||||
return
|
||||
}
|
||||
if (readerManager.currentMode != mode) {
|
||||
readerManager.replace(mode)
|
||||
}
|
||||
if (viewBinding.appbarTop.isVisible) {
|
||||
lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable)
|
||||
}
|
||||
viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED)
|
||||
viewBinding.timerControl.onReaderModeChanged(mode)
|
||||
}
|
||||
override fun onZoomIn() {
|
||||
readerManager.currentReader?.onZoomIn()
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
|
||||
val (isLoading, hasPages) = value
|
||||
val showLoadingLayout = isLoading && !hasPages
|
||||
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
|
||||
val transition = Fade().addTarget(viewBinding.layoutLoading)
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
viewBinding.layoutLoading.isVisible = showLoadingLayout
|
||||
}
|
||||
if (isLoading && hasPages) {
|
||||
viewBinding.toastView.show(R.string.loading_)
|
||||
} else {
|
||||
viewBinding.toastView.hide()
|
||||
}
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
override fun onZoomOut() {
|
||||
readerManager.currentReader?.onZoomOut()
|
||||
}
|
||||
|
||||
override fun onGridTouch(area: TapGridArea): Boolean {
|
||||
return isReaderResumed() && controlDelegate.onGridTouch(area)
|
||||
}
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_timer -> onScrollTimerClick(isLongClick = false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGridLongTouch(area: TapGridArea) {
|
||||
if (isReaderResumed()) {
|
||||
controlDelegate.onGridLongTouch(area)
|
||||
}
|
||||
}
|
||||
private fun onInitReader(mode: ReaderMode?) {
|
||||
if (mode == null) {
|
||||
return
|
||||
}
|
||||
if (readerManager.currentMode != mode) {
|
||||
readerManager.replace(mode)
|
||||
}
|
||||
if (viewBinding.appbarTop.isVisible) {
|
||||
lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable)
|
||||
}
|
||||
viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED)
|
||||
viewBinding.timerControl.onReaderModeChanged(mode)
|
||||
}
|
||||
|
||||
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
|
||||
return if (
|
||||
rawX <= gestureInsets.left ||
|
||||
rawY <= gestureInsets.top ||
|
||||
rawX >= viewBinding.root.width - gestureInsets.right ||
|
||||
rawY >= viewBinding.root.height - gestureInsets.bottom ||
|
||||
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) ||
|
||||
viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
val touchables = window.peekDecorView()?.touchables
|
||||
touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false
|
||||
}
|
||||
}
|
||||
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
|
||||
val (isLoading, hasPages) = value
|
||||
val showLoadingLayout = isLoading && !hasPages
|
||||
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
|
||||
val transition = Fade().addTarget(viewBinding.layoutLoading)
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
viewBinding.layoutLoading.isVisible = showLoadingLayout
|
||||
}
|
||||
if (isLoading && hasPages) {
|
||||
viewBinding.toastView.show(R.string.loading_)
|
||||
} else {
|
||||
viewBinding.toastView.hide()
|
||||
}
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
touchHelper.dispatchTouchEvent(ev)
|
||||
if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) {
|
||||
scrollTimer.onTouchEvent(ev)
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
override fun onGridTouch(area: TapGridArea): Boolean {
|
||||
return isReaderResumed() && controlDelegate.onGridTouch(area)
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
|
||||
}
|
||||
override fun onGridLongTouch(area: TapGridArea) {
|
||||
if (isReaderResumed()) {
|
||||
controlDelegate.onGridLongTouch(area)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||
}
|
||||
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
|
||||
return if (
|
||||
rawX <= gestureInsets.left ||
|
||||
rawY <= gestureInsets.top ||
|
||||
rawX >= viewBinding.root.width - gestureInsets.right ||
|
||||
rawY >= viewBinding.root.height - gestureInsets.bottom ||
|
||||
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) ||
|
||||
viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
val touchables = window.peekDecorView()?.touchables
|
||||
touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChapterSelected(chapter: MangaChapter): Boolean {
|
||||
viewModel.switchChapter(chapter.id, 0)
|
||||
return true
|
||||
}
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
touchHelper.dispatchTouchEvent(ev)
|
||||
if (!viewBinding.timerControl.hasGlobalPoint(ev.rawX.toInt(), ev.rawY.toInt())) {
|
||||
scrollTimer.onTouchEvent(ev)
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
override fun onPageSelected(page: ReaderPage): Boolean {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
val pages = viewModel.content.value.pages
|
||||
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
|
||||
if (index != -1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
readerManager.currentReader?.switchPageTo(index, true)
|
||||
}
|
||||
} else {
|
||||
viewModel.switchChapter(page.chapterId, page.index)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onReaderModeChanged(mode: ReaderMode) {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.switchMode(mode)
|
||||
viewBinding.timerControl.onReaderModeChanged(mode)
|
||||
}
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onDoubleModeChanged(isEnabled: Boolean) {
|
||||
readerManager.setDoubleReaderMode(isEnabled)
|
||||
}
|
||||
override fun onChapterSelected(chapter: MangaChapter): Boolean {
|
||||
viewModel.switchChapter(chapter.id, 0)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||
if (isKeep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
override fun onPageSelected(page: ReaderPage): Boolean {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
val pages = viewModel.content.value.pages
|
||||
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
|
||||
if (index != -1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
readerManager.currentReader?.switchPageTo(index, true)
|
||||
}
|
||||
} else {
|
||||
viewModel.switchChapter(page.chapterId, page.index)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setUiIsVisible(isUiVisible: Boolean) {
|
||||
if (viewBinding.appbarTop.isVisible != isUiVisible) {
|
||||
if (isAnimationsEnabled) {
|
||||
val transition = TransitionSet()
|
||||
.setOrdering(TransitionSet.ORDERING_TOGETHER)
|
||||
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
|
||||
.addTransition(Fade().addTarget(viewBinding.infoBar))
|
||||
viewBinding.toolbarDocked?.let {
|
||||
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it))
|
||||
}
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
}
|
||||
val isFullscreen = settings.isReaderFullscreenEnabled
|
||||
viewBinding.appbarTop.isVisible = isUiVisible
|
||||
viewBinding.toolbarDocked?.isVisible = isUiVisible
|
||||
viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
|
||||
viewBinding.infoBar.isTimeVisible = isFullscreen
|
||||
updateScrollTimerButton()
|
||||
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
|
||||
viewBinding.root.requestApplyInsets()
|
||||
}
|
||||
}
|
||||
override fun onReaderModeChanged(mode: ReaderMode) {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.switchMode(mode)
|
||||
viewBinding.timerControl.onReaderModeChanged(mode)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = systemBars.top
|
||||
rightMargin = systemBars.right
|
||||
leftMargin = systemBars.left
|
||||
}
|
||||
if (viewBinding.toolbarDocked != null) {
|
||||
viewBinding.actionsView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = systemBars.bottom
|
||||
rightMargin = systemBars.right
|
||||
leftMargin = systemBars.left
|
||||
}
|
||||
}
|
||||
viewBinding.infoBar.updatePadding(
|
||||
top = systemBars.top,
|
||||
)
|
||||
val innerInsets = Insets.of(
|
||||
systemBars.left,
|
||||
if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top,
|
||||
systemBars.right,
|
||||
viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom,
|
||||
)
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets)
|
||||
.build()
|
||||
}
|
||||
override fun onDoubleModeChanged(isEnabled: Boolean) {
|
||||
// Combine manual toggle with foldable auto setting
|
||||
applyDoubleModeAuto(isEnabled)
|
||||
}
|
||||
|
||||
override fun switchPageBy(delta: Int) {
|
||||
readerManager.currentReader?.switchPageBy(delta)
|
||||
}
|
||||
private fun applyDoubleModeAuto(manualEnabled: Boolean? = null) {
|
||||
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
// Auto double-page on foldable when device is unfolded (half-opened or flat)
|
||||
val autoFoldable = settings.isReaderDoubleOnFoldable && isFoldUnfolded
|
||||
val manualLandscape = (manualEnabled ?: settings.isReaderDoubleOnLandscape) && isLandscape
|
||||
val autoEnabled = autoFoldable || manualLandscape
|
||||
readerManager.setDoubleReaderMode(autoEnabled)
|
||||
}
|
||||
|
||||
override fun switchChapterBy(delta: Int) {
|
||||
viewModel.switchChapterBy(delta)
|
||||
}
|
||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||
if (isKeep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
override fun openMenu() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
val currentMode = readerManager.currentMode ?: return
|
||||
router.showReaderConfigSheet(currentMode)
|
||||
}
|
||||
private fun setUiIsVisible(isUiVisible: Boolean) {
|
||||
if (viewBinding.appbarTop.isVisible != isUiVisible) {
|
||||
if (isAnimationsEnabled) {
|
||||
val transition = TransitionSet()
|
||||
.setOrdering(TransitionSet.ORDERING_TOGETHER)
|
||||
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
|
||||
.addTransition(Fade().addTarget(viewBinding.infoBar))
|
||||
viewBinding.toolbarDocked?.let {
|
||||
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it))
|
||||
}
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
}
|
||||
val isFullscreen = settings.isReaderFullscreenEnabled
|
||||
viewBinding.appbarTop.isVisible = isUiVisible
|
||||
viewBinding.toolbarDocked?.isVisible = isUiVisible
|
||||
viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value)
|
||||
viewBinding.infoBar.isTimeVisible = isFullscreen
|
||||
updateScrollTimerButton()
|
||||
systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen)
|
||||
viewBinding.root.requestApplyInsets()
|
||||
}
|
||||
}
|
||||
|
||||
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
|
||||
return readerManager.currentReader?.scrollBy(delta, smooth) == true
|
||||
}
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = systemBars.top
|
||||
rightMargin = systemBars.right
|
||||
leftMargin = systemBars.left
|
||||
}
|
||||
if (viewBinding.toolbarDocked != null) {
|
||||
viewBinding.actionsView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = systemBars.bottom
|
||||
rightMargin = systemBars.right
|
||||
leftMargin = systemBars.left
|
||||
}
|
||||
}
|
||||
viewBinding.infoBar.updatePadding(
|
||||
top = systemBars.top,
|
||||
)
|
||||
val innerInsets = Insets.of(
|
||||
systemBars.left,
|
||||
if (viewBinding.appbarTop.isVisible) viewBinding.appbarTop.height else systemBars.top,
|
||||
systemBars.right,
|
||||
viewBinding.toolbarDocked?.takeIf { it.isVisible }?.height ?: systemBars.bottom,
|
||||
)
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(WindowInsetsCompat.Type.systemBars(), innerInsets)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun toggleUiVisibility() {
|
||||
setUiIsVisible(!viewBinding.appbarTop.isVisible)
|
||||
}
|
||||
override fun switchPageBy(delta: Int) {
|
||||
readerManager.currentReader?.switchPageBy(delta)
|
||||
}
|
||||
|
||||
override fun isReaderResumed(): Boolean {
|
||||
val reader = readerManager.currentReader ?: return false
|
||||
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
|
||||
}
|
||||
override fun switchChapterBy(delta: Int) {
|
||||
viewModel.switchChapterBy(delta)
|
||||
}
|
||||
|
||||
override fun onBookmarkClick() {
|
||||
viewModel.toggleBookmark()
|
||||
}
|
||||
override fun openMenu() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
val currentMode = readerManager.currentMode ?: return
|
||||
router.showReaderConfigSheet(currentMode)
|
||||
}
|
||||
|
||||
override fun onSavePageClick() {
|
||||
viewModel.saveCurrentPage(pageSaveHelper)
|
||||
}
|
||||
override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
|
||||
return readerManager.currentReader?.scrollBy(delta, smooth) == true
|
||||
}
|
||||
|
||||
override fun onScrollTimerClick(isLongClick: Boolean) {
|
||||
if (isLongClick) {
|
||||
scrollTimer.setActive(!scrollTimer.isActive.value)
|
||||
} else {
|
||||
viewBinding.timerControl.showOrHide()
|
||||
}
|
||||
}
|
||||
override fun toggleUiVisibility() {
|
||||
setUiIsVisible(!viewBinding.appbarTop.isVisible)
|
||||
}
|
||||
|
||||
override fun toggleScreenOrientation() {
|
||||
if (screenOrientationHelper.toggleScreenOrientation()) {
|
||||
Snackbar.make(
|
||||
viewBinding.container,
|
||||
if (screenOrientationHelper.isLocked) {
|
||||
R.string.screen_rotation_locked
|
||||
} else {
|
||||
R.string.screen_rotation_unlocked
|
||||
},
|
||||
Snackbar.LENGTH_SHORT,
|
||||
).setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
override fun isReaderResumed(): Boolean {
|
||||
val reader = readerManager.currentReader ?: return false
|
||||
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
|
||||
}
|
||||
|
||||
override fun switchPageTo(index: Int) {
|
||||
val pages = viewModel.getCurrentChapterPages()
|
||||
val page = pages?.getOrNull(index) ?: return
|
||||
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
|
||||
onPageSelected(ReaderPage(page, index, chapterId))
|
||||
}
|
||||
override fun onBookmarkClick() {
|
||||
viewModel.toggleBookmark()
|
||||
}
|
||||
|
||||
private fun onReaderBarChanged(isBarEnabled: Boolean) {
|
||||
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
|
||||
}
|
||||
override fun onSavePageClick() {
|
||||
viewModel.saveCurrentPage(pageSaveHelper)
|
||||
}
|
||||
|
||||
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
|
||||
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair
|
||||
title = uiState?.mangaName ?: getString(R.string.loading_)
|
||||
viewBinding.infoBar.update(uiState)
|
||||
if (uiState == null) {
|
||||
supportActionBar?.subtitle = null
|
||||
viewBinding.actionsView.setSliderValue(0, 1)
|
||||
viewBinding.actionsView.isSliderEnabled = false
|
||||
return
|
||||
}
|
||||
val chapterTitle = uiState.getChapterTitle(resources)
|
||||
supportActionBar?.subtitle = when {
|
||||
uiState.incognito -> getString(R.string.incognito_mode)
|
||||
else -> chapterTitle
|
||||
}
|
||||
if (
|
||||
settings.isReaderChapterToastEnabled &&
|
||||
chapterTitle != previous?.getChapterTitle(resources) &&
|
||||
chapterTitle.isNotEmpty()
|
||||
) {
|
||||
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
|
||||
}
|
||||
if (uiState.isSliderAvailable()) {
|
||||
viewBinding.actionsView.setSliderValue(
|
||||
value = uiState.currentPage,
|
||||
max = uiState.totalPages - 1,
|
||||
)
|
||||
} else {
|
||||
viewBinding.actionsView.setSliderValue(0, 1)
|
||||
}
|
||||
viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable()
|
||||
viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter()
|
||||
viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter()
|
||||
}
|
||||
override fun onScrollTimerClick(isLongClick: Boolean) {
|
||||
if (isLongClick) {
|
||||
scrollTimer.setActive(!scrollTimer.isActive.value)
|
||||
} else {
|
||||
viewBinding.timerControl.showOrHide()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateScrollTimerButton() {
|
||||
val button = viewBinding.buttonTimer ?: return
|
||||
val isButtonVisible = scrollTimer.isActive.value
|
||||
&& settings.isReaderAutoscrollFabVisible
|
||||
&& !viewBinding.appbarTop.isVisible
|
||||
&& !viewBinding.timerControl.isVisible
|
||||
if (button.isVisible != isButtonVisible) {
|
||||
val transition = Fade().addTarget(button)
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
button.isVisible = isButtonVisible
|
||||
}
|
||||
}
|
||||
override fun toggleScreenOrientation() {
|
||||
if (screenOrientationHelper.toggleScreenOrientation()) {
|
||||
Snackbar.make(
|
||||
viewBinding.container,
|
||||
if (screenOrientationHelper.isLocked) {
|
||||
R.string.screen_rotation_locked
|
||||
} else {
|
||||
R.string.screen_rotation_unlocked
|
||||
},
|
||||
Snackbar.LENGTH_SHORT,
|
||||
).setAnchorView(viewBinding.toolbarDocked)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun askForIncognitoMode() {
|
||||
buildAlertDialog(this, isCentered = true) {
|
||||
var dontAskAgain = false
|
||||
val listener = DialogInterface.OnClickListener { _, which ->
|
||||
if (which == DialogInterface.BUTTON_NEUTRAL) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
viewModel.setIncognitoMode(which == DialogInterface.BUTTON_POSITIVE, dontAskAgain)
|
||||
}
|
||||
}
|
||||
setCheckbox(R.string.dont_ask_again, dontAskAgain) { _, isChecked ->
|
||||
dontAskAgain = isChecked
|
||||
}
|
||||
setIcon(R.drawable.ic_incognito)
|
||||
setTitle(R.string.incognito_mode)
|
||||
setMessage(R.string.incognito_mode_hint_nsfw)
|
||||
setPositiveButton(R.string.incognito, listener)
|
||||
setNegativeButton(R.string.disable, listener)
|
||||
setNeutralButton(android.R.string.cancel, listener)
|
||||
setOnCancelListener { finishAfterTransition() }
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
override fun switchPageTo(index: Int) {
|
||||
val pages = viewModel.getCurrentChapterPages()
|
||||
val page = pages?.getOrNull(index) ?: return
|
||||
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
|
||||
onPageSelected(ReaderPage(page, index, chapterId))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun onReaderBarChanged(isBarEnabled: Boolean) {
|
||||
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
|
||||
}
|
||||
|
||||
private const val TOAST_DURATION = 2000L
|
||||
}
|
||||
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
|
||||
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair
|
||||
title = uiState?.mangaName ?: getString(R.string.loading_)
|
||||
viewBinding.infoBar.update(uiState)
|
||||
if (uiState == null) {
|
||||
supportActionBar?.subtitle = null
|
||||
viewBinding.actionsView.setSliderValue(0, 1)
|
||||
viewBinding.actionsView.isSliderEnabled = false
|
||||
return
|
||||
}
|
||||
val chapterTitle = uiState.getChapterTitle(resources)
|
||||
supportActionBar?.subtitle = when {
|
||||
uiState.incognito -> getString(R.string.incognito_mode)
|
||||
else -> chapterTitle
|
||||
}
|
||||
if (
|
||||
settings.isReaderChapterToastEnabled &&
|
||||
chapterTitle != previous?.getChapterTitle(resources) &&
|
||||
chapterTitle.isNotEmpty()
|
||||
) {
|
||||
viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION)
|
||||
}
|
||||
if (uiState.isSliderAvailable()) {
|
||||
viewBinding.actionsView.setSliderValue(
|
||||
value = uiState.currentPage,
|
||||
max = uiState.totalPages - 1,
|
||||
)
|
||||
} else {
|
||||
viewBinding.actionsView.setSliderValue(0, 1)
|
||||
}
|
||||
viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable()
|
||||
viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter()
|
||||
viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter()
|
||||
}
|
||||
|
||||
private fun updateScrollTimerButton() {
|
||||
val button = viewBinding.buttonTimer ?: return
|
||||
val isButtonVisible = scrollTimer.isActive.value
|
||||
&& settings.isReaderAutoscrollFabVisible
|
||||
&& !viewBinding.appbarTop.isVisible
|
||||
&& !viewBinding.timerControl.isVisible
|
||||
if (button.isVisible != isButtonVisible) {
|
||||
val transition = Fade().addTarget(button)
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
button.isVisible = isButtonVisible
|
||||
}
|
||||
}
|
||||
|
||||
// Observe foldable window layout to auto-enable double-page if configured
|
||||
private fun observeWindowLayout() {
|
||||
WindowInfoTracker.getOrCreate(this)
|
||||
.windowLayoutInfo(this)
|
||||
.onEach { info ->
|
||||
val fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
|
||||
val unfolded = when (fold?.state) {
|
||||
FoldingFeature.State.HALF_OPENED, FoldingFeature.State.FLAT -> true
|
||||
else -> false
|
||||
}
|
||||
if (unfolded != isFoldUnfolded) {
|
||||
isFoldUnfolded = unfolded
|
||||
applyDoubleModeAuto()
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun askForIncognitoMode() {
|
||||
buildAlertDialog(this, isCentered = true) {
|
||||
var dontAskAgain = false
|
||||
val listener = DialogInterface.OnClickListener { _, which ->
|
||||
if (which == DialogInterface.BUTTON_NEUTRAL) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
viewModel.setIncognitoMode(which == DialogInterface.BUTTON_POSITIVE, dontAskAgain)
|
||||
}
|
||||
}
|
||||
setCheckbox(R.string.dont_ask_again, dontAskAgain) { _, isChecked ->
|
||||
dontAskAgain = isChecked
|
||||
}
|
||||
setIcon(R.drawable.ic_incognito)
|
||||
setTitle(R.string.incognito_mode)
|
||||
setMessage(R.string.incognito_mode_hint_nsfw)
|
||||
setPositiveButton(R.string.incognito, listener)
|
||||
setNegativeButton(R.string.disable, listener)
|
||||
setNeutralButton(android.R.string.cancel, listener)
|
||||
setOnCancelListener { finishAfterTransition() }
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TOAST_DURATION = 2000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class ReaderManager(
|
||||
fun setDoubleReaderMode(isEnabled: Boolean) {
|
||||
val mode = currentMode
|
||||
val prevReader = currentReader?.javaClass
|
||||
invalidateTypesMap(isEnabled && isLandscape())
|
||||
invalidateTypesMap(isEnabled)
|
||||
val newReader = modeMap[mode]
|
||||
if (mode != null && newReader != prevReader) {
|
||||
replace(mode)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,228 +38,244 @@ import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReaderConfigSheet :
|
||||
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
|
||||
View.OnClickListener,
|
||||
MaterialButtonToggleGroup.OnButtonCheckedListener,
|
||||
CompoundButton.OnCheckedChangeListener,
|
||||
Slider.OnChangeListener {
|
||||
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
|
||||
View.OnClickListener,
|
||||
MaterialButtonToggleGroup.OnButtonCheckedListener,
|
||||
CompoundButton.OnCheckedChangeListener,
|
||||
Slider.OnChangeListener {
|
||||
|
||||
private val viewModel by activityViewModels<ReaderViewModel>()
|
||||
private val viewModel by activityViewModels<ReaderViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var orientationHelper: ScreenOrientationHelper
|
||||
@Inject
|
||||
lateinit var orientationHelper: ScreenOrientationHelper
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
|
||||
private lateinit var mode: ReaderMode
|
||||
private lateinit var imageServerDelegate: ImageServerDelegate
|
||||
private lateinit var mode: ReaderMode
|
||||
private lateinit var imageServerDelegate: ImageServerDelegate
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
|
||||
?.let { ReaderMode.valueOf(it) }
|
||||
?: ReaderMode.STANDARD
|
||||
imageServerDelegate = ImageServerDelegate(
|
||||
mangaRepositoryFactory = mangaRepositoryFactory,
|
||||
mangaSource = viewModel.getMangaOrNull()?.source,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
): SheetReaderConfigBinding {
|
||||
return SheetReaderConfigBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(
|
||||
binding: SheetReaderConfigBinding,
|
||||
savedInstanceState: Bundle?,
|
||||
) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
observeScreenOrientation()
|
||||
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
|
||||
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
|
||||
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
|
||||
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
|
||||
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
|
||||
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
|
||||
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
|
||||
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
|
||||
binding.adjustSensitivitySlider(withAnimation = false)
|
||||
|
||||
binding.checkableGroup.addOnButtonCheckedListener(this)
|
||||
binding.buttonSavePage.setOnClickListener(this)
|
||||
binding.buttonScreenRotate.setOnClickListener(this)
|
||||
binding.buttonSettings.setOnClickListener(this)
|
||||
binding.buttonImageServer.setOnClickListener(this)
|
||||
binding.buttonColorFilter.setOnClickListener(this)
|
||||
binding.buttonScrollTimer.setOnClickListener(this)
|
||||
binding.buttonBookmark.setOnClickListener(this)
|
||||
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
||||
binding.sliderDoubleSensitivity.addOnChangeListener(this)
|
||||
|
||||
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
||||
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
|
||||
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
|
||||
)
|
||||
}
|
||||
|
||||
viewLifecycleScope.launch {
|
||||
val isAvailable = imageServerDelegate.isAvailable()
|
||||
if (isAvailable) {
|
||||
bindImageServerTitle()
|
||||
}
|
||||
binding.buttonImageServer.isVisible = isAvailable
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||
viewBinding?.scrollView?.updatePadding(
|
||||
bottom = insets.getInsets(typeMask).bottom,
|
||||
)
|
||||
return insets.consume(v, typeMask, bottom = true)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_settings -> {
|
||||
router.openReaderSettings()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_scroll_timer -> {
|
||||
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_save_page -> {
|
||||
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_screen_rotate -> {
|
||||
orientationHelper.isLandscape = !orientationHelper.isLandscape
|
||||
}
|
||||
|
||||
R.id.button_bookmark -> {
|
||||
viewModel.toggleBookmark()
|
||||
}
|
||||
|
||||
R.id.button_color_filter -> {
|
||||
val page = viewModel.getCurrentPage() ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.openColorFilterConfig(manga, page)
|
||||
}
|
||||
|
||||
R.id.button_image_server -> viewLifecycleScope.launch {
|
||||
if (imageServerDelegate.showDialog(v.context)) {
|
||||
bindImageServerTitle()
|
||||
pageLoader.invalidate(clearCache = true)
|
||||
viewModel.switchChapterBy(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
when (buttonView.id) {
|
||||
R.id.switch_screen_lock_rotation -> {
|
||||
orientationHelper.isLocked = isChecked
|
||||
}
|
||||
|
||||
R.id.switch_double_reader -> {
|
||||
settings.isReaderDoubleOnLandscape = isChecked
|
||||
viewBinding?.adjustSensitivitySlider(withAnimation = true)
|
||||
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
settings.readerDoublePagesSensitivity = value / 100f
|
||||
}
|
||||
|
||||
override fun onButtonChecked(
|
||||
group: MaterialButtonToggleGroup?,
|
||||
checkedId: Int,
|
||||
isChecked: Boolean,
|
||||
) {
|
||||
if (!isChecked) {
|
||||
return
|
||||
}
|
||||
val newMode = when (checkedId) {
|
||||
R.id.button_standard -> ReaderMode.STANDARD
|
||||
R.id.button_webtoon -> ReaderMode.WEBTOON
|
||||
R.id.button_reversed -> ReaderMode.REVERSED
|
||||
R.id.button_vertical -> ReaderMode.VERTICAL
|
||||
else -> return
|
||||
}
|
||||
viewBinding?.run {
|
||||
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
||||
adjustSensitivitySlider(withAnimation = true)
|
||||
}
|
||||
if (newMode == mode) {
|
||||
return
|
||||
}
|
||||
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
|
||||
mode = newMode
|
||||
}
|
||||
|
||||
private fun observeScreenOrientation() {
|
||||
orientationHelper.observeAutoOrientation()
|
||||
.onEach {
|
||||
with(requireViewBinding()) {
|
||||
buttonScreenRotate.isGone = it
|
||||
switchScreenLockRotation.isVisible = it
|
||||
updateOrientationLockSwitch()
|
||||
}
|
||||
}.launchIn(viewLifecycleScope)
|
||||
}
|
||||
|
||||
private fun updateOrientationLockSwitch() {
|
||||
val switch = viewBinding?.switchScreenLockRotation ?: return
|
||||
switch.setOnCheckedChangeListener(null)
|
||||
switch.isChecked = orientationHelper.isLocked
|
||||
switch.setOnCheckedChangeListener(this)
|
||||
}
|
||||
|
||||
private suspend fun bindImageServerTitle() {
|
||||
viewBinding?.buttonImageServer?.text = getString(
|
||||
R.string.inline_preference_pattern,
|
||||
getString(R.string.image_server),
|
||||
imageServerDelegate.getValue() ?: getString(R.string.automatic),
|
||||
)
|
||||
}
|
||||
|
||||
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
|
||||
val isSliderVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
|
||||
if (isSliderVisible != sliderDoubleSensitivity.isVisible && withAnimation) {
|
||||
TransitionManager.beginDelayedTransition(layoutMain)
|
||||
}
|
||||
sliderDoubleSensitivity.isVisible = isSliderVisible
|
||||
textDoubleSensitivity.isVisible = isSliderVisible
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
mode = arguments?.getInt(AppRouter.KEY_READER_MODE)
|
||||
?.let { ReaderMode.valueOf(it) }
|
||||
?: ReaderMode.STANDARD
|
||||
imageServerDelegate = ImageServerDelegate(
|
||||
mangaRepositoryFactory = mangaRepositoryFactory,
|
||||
mangaSource = viewModel.getMangaOrNull()?.source,
|
||||
)
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
): SheetReaderConfigBinding {
|
||||
return SheetReaderConfigBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
fun onReaderModeChanged(mode: ReaderMode)
|
||||
override fun onViewBindingCreated(
|
||||
binding: SheetReaderConfigBinding,
|
||||
savedInstanceState: Bundle?,
|
||||
) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
observeScreenOrientation()
|
||||
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
|
||||
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
|
||||
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
|
||||
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
|
||||
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
|
||||
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
|
||||
binding.switchDoubleFoldable.isChecked = settings.isReaderDoubleOnFoldable
|
||||
binding.switchDoubleFoldable.isEnabled = binding.switchDoubleReader.isEnabled
|
||||
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
|
||||
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
|
||||
binding.adjustSensitivitySlider(withAnimation = false)
|
||||
|
||||
fun onDoubleModeChanged(isEnabled: Boolean)
|
||||
binding.checkableGroup.addOnButtonCheckedListener(this)
|
||||
binding.buttonSavePage.setOnClickListener(this)
|
||||
binding.buttonScreenRotate.setOnClickListener(this)
|
||||
binding.buttonSettings.setOnClickListener(this)
|
||||
binding.buttonImageServer.setOnClickListener(this)
|
||||
binding.buttonColorFilter.setOnClickListener(this)
|
||||
binding.buttonScrollTimer.setOnClickListener(this)
|
||||
binding.buttonBookmark.setOnClickListener(this)
|
||||
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
||||
binding.switchDoubleFoldable.setOnCheckedChangeListener(this)
|
||||
binding.sliderDoubleSensitivity.addOnChangeListener(this)
|
||||
|
||||
fun onSavePageClick()
|
||||
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
||||
binding.buttonBookmark.setText(if (it) R.string.bookmark_remove else R.string.bookmark_add)
|
||||
binding.buttonBookmark.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
if (it) R.drawable.ic_bookmark_checked else R.drawable.ic_bookmark, 0, 0, 0,
|
||||
)
|
||||
}
|
||||
|
||||
fun onScrollTimerClick(isLongClick: Boolean)
|
||||
viewLifecycleScope.launch {
|
||||
val isAvailable = imageServerDelegate.isAvailable()
|
||||
if (isAvailable) {
|
||||
bindImageServerTitle()
|
||||
}
|
||||
binding.buttonImageServer.isVisible = isAvailable
|
||||
}
|
||||
}
|
||||
|
||||
fun onBookmarkClick()
|
||||
}
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val typeMask = WindowInsetsCompat.Type.systemBars()
|
||||
viewBinding?.scrollView?.updatePadding(
|
||||
bottom = insets.getInsets(typeMask).bottom,
|
||||
)
|
||||
return insets.consume(v, typeMask, bottom = true)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_settings -> {
|
||||
router.openReaderSettings()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_scroll_timer -> {
|
||||
findParentCallback(Callback::class.java)?.onScrollTimerClick(false) ?: return
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_save_page -> {
|
||||
findParentCallback(Callback::class.java)?.onSavePageClick() ?: return
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_screen_rotate -> {
|
||||
orientationHelper.isLandscape = !orientationHelper.isLandscape
|
||||
}
|
||||
|
||||
R.id.button_bookmark -> {
|
||||
viewModel.toggleBookmark()
|
||||
}
|
||||
|
||||
R.id.button_color_filter -> {
|
||||
val page = viewModel.getCurrentPage() ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
router.openColorFilterConfig(manga, page)
|
||||
}
|
||||
|
||||
R.id.button_image_server -> viewLifecycleScope.launch {
|
||||
if (imageServerDelegate.showDialog(v.context)) {
|
||||
bindImageServerTitle()
|
||||
pageLoader.invalidate(clearCache = true)
|
||||
viewModel.switchChapterBy(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
when (buttonView.id) {
|
||||
R.id.switch_screen_lock_rotation -> {
|
||||
orientationHelper.isLocked = isChecked
|
||||
}
|
||||
|
||||
R.id.switch_double_reader -> {
|
||||
settings.isReaderDoubleOnLandscape = isChecked
|
||||
viewBinding?.adjustSensitivitySlider(withAnimation = true)
|
||||
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
|
||||
}
|
||||
|
||||
R.id.switch_double_foldable -> {
|
||||
settings.isReaderDoubleOnFoldable = isChecked
|
||||
// Re-evaluate double-page considering foldable state and current manual toggle
|
||||
findParentCallback(Callback::class.java)?.onDoubleModeChanged(settings.isReaderDoubleOnLandscape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
settings.readerDoublePagesSensitivity = value / 100f
|
||||
}
|
||||
|
||||
override fun onButtonChecked(
|
||||
group: MaterialButtonToggleGroup?,
|
||||
checkedId: Int,
|
||||
isChecked: Boolean,
|
||||
) {
|
||||
if (!isChecked) {
|
||||
return
|
||||
}
|
||||
val newMode = when (checkedId) {
|
||||
R.id.button_standard -> ReaderMode.STANDARD
|
||||
R.id.button_webtoon -> ReaderMode.WEBTOON
|
||||
R.id.button_reversed -> ReaderMode.REVERSED
|
||||
R.id.button_vertical -> ReaderMode.VERTICAL
|
||||
else -> return
|
||||
}
|
||||
viewBinding?.run {
|
||||
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
||||
switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled
|
||||
adjustSensitivitySlider(withAnimation = true)
|
||||
}
|
||||
if (newMode == mode) {
|
||||
return
|
||||
}
|
||||
findParentCallback(Callback::class.java)?.onReaderModeChanged(newMode) ?: return
|
||||
mode = newMode
|
||||
}
|
||||
|
||||
private fun observeScreenOrientation() {
|
||||
orientationHelper.observeAutoOrientation()
|
||||
.onEach {
|
||||
with(requireViewBinding()) {
|
||||
buttonScreenRotate.isGone = it
|
||||
switchScreenLockRotation.isVisible = it
|
||||
updateOrientationLockSwitch()
|
||||
}
|
||||
}.launchIn(viewLifecycleScope)
|
||||
}
|
||||
|
||||
private fun updateOrientationLockSwitch() {
|
||||
val switch = viewBinding?.switchScreenLockRotation ?: return
|
||||
switch.setOnCheckedChangeListener(null)
|
||||
switch.isChecked = orientationHelper.isLocked
|
||||
switch.setOnCheckedChangeListener(this)
|
||||
}
|
||||
|
||||
private suspend fun bindImageServerTitle() {
|
||||
viewBinding?.buttonImageServer?.text = getString(
|
||||
R.string.inline_preference_pattern,
|
||||
getString(R.string.image_server),
|
||||
imageServerDelegate.getValue() ?: getString(R.string.automatic),
|
||||
)
|
||||
}
|
||||
|
||||
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
|
||||
val isSubOptionsVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
|
||||
val needTransition = withAnimation && (
|
||||
(isSubOptionsVisible != sliderDoubleSensitivity.isVisible) ||
|
||||
(isSubOptionsVisible != textDoubleSensitivity.isVisible) ||
|
||||
(isSubOptionsVisible != switchDoubleFoldable.isVisible)
|
||||
)
|
||||
if (needTransition) {
|
||||
TransitionManager.beginDelayedTransition(layoutMain)
|
||||
}
|
||||
sliderDoubleSensitivity.isVisible = isSubOptionsVisible
|
||||
textDoubleSensitivity.isVisible = isSubOptionsVisible
|
||||
switchDoubleFoldable.isVisible = isSubOptionsVisible
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
fun onReaderModeChanged(mode: ReaderMode)
|
||||
|
||||
fun onDoubleModeChanged(isEnabled: Boolean)
|
||||
|
||||
fun onSavePageClick()
|
||||
|
||||
fun onScrollTimerClick(isLongClick: Boolean)
|
||||
|
||||
fun onBookmarkClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,26 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
|
||||
readerAdapter = onCreateAdapter()
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner) {
|
||||
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
|
||||
onPagesChanged(it.pages, viewModel.getCurrentState())
|
||||
} else {
|
||||
onPagesChanged(it.pages, it.state)
|
||||
// Determine which state to use for restoring position:
|
||||
// - content.state: explicitly set state (e.g., after mode switch or chapter change)
|
||||
// - getCurrentState(): current reading position saved in SavedStateHandle
|
||||
val currentState = viewModel.getCurrentState()
|
||||
val pendingState = when {
|
||||
// If content.state is null and we have pages, use getCurrentState
|
||||
it.state == null
|
||||
&& it.pages.isNotEmpty()
|
||||
&& readerAdapter?.hasItems != true -> currentState
|
||||
|
||||
// use currentState only if it matches the current pages (to avoid the error message)
|
||||
readerAdapter?.hasItems != true
|
||||
&& it.state != currentState
|
||||
&& currentState != null
|
||||
&& it.pages.any { page -> page.chapterId == currentState.chapterId } -> currentState
|
||||
|
||||
// Otherwise, use content.state (normal flow, mode switch, chapter change)
|
||||
else -> it.state
|
||||
}
|
||||
onPagesChanged(it.pages, pendingState)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class SearchActivity :
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||
supportActionBar?.setSubtitle(R.string.search_results)
|
||||
|
||||
addMenuProvider(SearchKindMenuProvider(this, viewModel.query, viewModel.kind))
|
||||
addMenuProvider(SearchMenuProvider(this, viewModel))
|
||||
|
||||
viewModel.list.observe(this, adapter)
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||
|
||||
@@ -9,10 +9,9 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
|
||||
class SearchKindMenuProvider(
|
||||
class SearchMenuProvider(
|
||||
private val activity: SearchActivity,
|
||||
private val query: String,
|
||||
private val kind: SearchKind
|
||||
private val viewModel: SearchViewModel,
|
||||
) : MenuProvider {
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
@@ -22,7 +21,7 @@ class SearchKindMenuProvider(
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(
|
||||
when (kind) {
|
||||
when (viewModel.kind) {
|
||||
SearchKind.SIMPLE -> R.id.action_kind_simple
|
||||
SearchKind.TITLE -> R.id.action_kind_title
|
||||
SearchKind.AUTHOR -> R.id.action_kind_author
|
||||
@@ -32,6 +31,20 @@ class SearchKindMenuProvider(
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_filter_pinned_only -> {
|
||||
menuItem.isChecked = !menuItem.isChecked
|
||||
viewModel.setPinnedOnly(menuItem.isChecked)
|
||||
return true
|
||||
}
|
||||
|
||||
R.id.action_filter_hide_empty -> {
|
||||
menuItem.isChecked = !menuItem.isChecked
|
||||
viewModel.setHideEmpty(menuItem.isChecked)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
val newKind = when (menuItem.itemId) {
|
||||
R.id.action_kind_simple -> SearchKind.SIMPLE
|
||||
R.id.action_kind_title -> SearchKind.TITLE
|
||||
@@ -39,9 +52,9 @@ class SearchKindMenuProvider(
|
||||
R.id.action_kind_tag -> SearchKind.TAG
|
||||
else -> return false
|
||||
}
|
||||
if (newKind != kind) {
|
||||
if (newKind != viewModel.kind) {
|
||||
activity.router.openSearch(
|
||||
query = query,
|
||||
query = viewModel.query,
|
||||
kind = newKind,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
@@ -62,6 +62,8 @@ class SearchViewModel @Inject constructor(
|
||||
val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE
|
||||
|
||||
private var includeDisabledSources = MutableStateFlow(false)
|
||||
private var pinnedOnly = MutableStateFlow(false)
|
||||
private var hideEmpty = MutableStateFlow(false)
|
||||
private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList())
|
||||
|
||||
private var searchJob: Job? = null
|
||||
@@ -70,9 +72,15 @@ class SearchViewModel @Inject constructor(
|
||||
results,
|
||||
isLoading.dropWhile { !it },
|
||||
includeDisabledSources,
|
||||
) { list, loading, includeDisabled ->
|
||||
hideEmpty,
|
||||
) { list, loading, includeDisabled, hideEmptyVal ->
|
||||
val filteredList = if (hideEmptyVal) {
|
||||
list.filter { it.list.isNotEmpty() }
|
||||
} else {
|
||||
list
|
||||
}
|
||||
when {
|
||||
list.isEmpty() -> listOf(
|
||||
filteredList.isEmpty() -> listOf(
|
||||
when {
|
||||
loading -> LoadingState
|
||||
else -> EmptyState(
|
||||
@@ -84,9 +92,9 @@ class SearchViewModel @Inject constructor(
|
||||
},
|
||||
)
|
||||
|
||||
loading -> list + LoadingFooter()
|
||||
includeDisabled -> list
|
||||
else -> list + ButtonFooter(R.string.search_disabled_sources)
|
||||
loading -> filteredList + LoadingFooter()
|
||||
includeDisabled -> filteredList
|
||||
else -> filteredList + ButtonFooter(R.string.search_disabled_sources)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
@@ -114,6 +122,17 @@ class SearchViewModel @Inject constructor(
|
||||
doSearch()
|
||||
}
|
||||
|
||||
fun setPinnedOnly(value: Boolean) {
|
||||
if (pinnedOnly.value != value) {
|
||||
pinnedOnly.value = value
|
||||
retry()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideEmpty(value: Boolean) {
|
||||
hideEmpty.value = value
|
||||
}
|
||||
|
||||
fun continueSearch() {
|
||||
if (includeDisabledSources.value) {
|
||||
return
|
||||
@@ -122,8 +141,12 @@ class SearchViewModel @Inject constructor(
|
||||
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||
includeDisabledSources.value = true
|
||||
prevJob?.join()
|
||||
val sources = sourcesRepository.getDisabledSources()
|
||||
.sortedByDescending { it.priority() }
|
||||
val sources = if (pinnedOnly.value) {
|
||||
emptyList()
|
||||
} else {
|
||||
sourcesRepository.getDisabledSources()
|
||||
.sortedByDescending { it.priority() }
|
||||
}
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
sources.map { source ->
|
||||
launch {
|
||||
@@ -142,7 +165,11 @@ class SearchViewModel @Inject constructor(
|
||||
appendResult(searchHistory())
|
||||
appendResult(searchFavorites())
|
||||
appendResult(searchLocal())
|
||||
val sources = sourcesRepository.getEnabledSources()
|
||||
val sources = if (pinnedOnly.value) {
|
||||
sourcesRepository.getPinnedSources().toList()
|
||||
} else {
|
||||
sourcesRepository.getEnabledSources()
|
||||
}
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
sources.map { source ->
|
||||
launch {
|
||||
|
||||
@@ -130,6 +130,21 @@
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
app:drawableStartCompat="@drawable/ic_split_horizontal" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switch_double_foldable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:text="@string/auto_double_foldable"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.GridTitle"
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_double_sensitivity"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -33,6 +33,20 @@
|
||||
android:title="@string/genre" />
|
||||
|
||||
</group>
|
||||
|
||||
<group android:id="@+id/group_search_filters">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_filter_pinned_only"
|
||||
android:checkable="true"
|
||||
android:title="@string/pinned_sources_only" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_filter_hide_empty"
|
||||
android:checkable="true"
|
||||
android:title="@string/hide_empty_sources" />
|
||||
|
||||
</group>
|
||||
</menu>
|
||||
|
||||
</item>
|
||||
|
||||
@@ -879,4 +879,6 @@
|
||||
<string name="download_default_directory">Standardni direktorij za preuzimanje manga</string>
|
||||
<string name="private_app_directory_warning">Ako deinstaliraš aplikaciju, ovaj će se direktorij izbrisati sa svim podacima</string>
|
||||
<string name="available_pattern">%1$s dostupno</string>
|
||||
<string name="pinned_sources_only">Samo prikvačeni izvori</string>
|
||||
<string name="hide_empty_sources">Sakrij prazne izvore</string>
|
||||
</resources>
|
||||
|
||||
@@ -883,4 +883,6 @@
|
||||
<string name="download_default_directory">Direktori default untuk mengunduh manga</string>
|
||||
<string name="private_app_directory_warning">Direktori ini beserta semua datanya akan dihapus jika Anda menghapus aplikasi</string>
|
||||
<string name="available_pattern">%1$s tersedia</string>
|
||||
<string name="pinned_sources_only">Hanya sumber yang disematkan</string>
|
||||
<string name="hide_empty_sources">Sembunyikan sumber kosong</string>
|
||||
</resources>
|
||||
|
||||
@@ -881,4 +881,7 @@
|
||||
<string name="download_default_directory">Diretório padrão onde baixar os mangás</string>
|
||||
<string name="private_app_directory_warning">Esse diretório e todo seu conteúdo será deletado se desinstalar a aplicação</string>
|
||||
<string name="available_pattern">%1$s disponível</string>
|
||||
<string name="hide_empty_sources">Esconder fontes vazias</string>
|
||||
<string name="pinned_sources_only">Apenas fontes fixadas</string>
|
||||
<string name="auto_double_foldable">Duas Páginas Automático em Tela Dobrável</string>
|
||||
</resources>
|
||||
|
||||
@@ -884,4 +884,7 @@
|
||||
<string name="private_app_directory_warning">Uygulamayı kaldırırsanız, bu dizin ve içindeki tüm veriler silinecektir</string>
|
||||
<string name="available_pattern">%1$s var</string>
|
||||
<string name="frequency_every_6_hours">6 saatte bir</string>
|
||||
<string name="pinned_sources_only">Yalnızca sabitlenen kaynaklar</string>
|
||||
<string name="hide_empty_sources">Boş kaynakları gizle</string>
|
||||
<string name="auto_double_foldable">Otomatik İki Sayfa Katlanabilir</string>
|
||||
</resources>
|
||||
|
||||
@@ -880,4 +880,11 @@
|
||||
<string name="data_removal">Вилучення даних</string>
|
||||
<string name="privacy">Конфіденційність</string>
|
||||
<string name="source_broken_warning">Це джерело манги позначено як непрацююче. Деякі функції можуть не працювати</string>
|
||||
<string name="frequency_every_6_hours">Кожні 6 годин</string>
|
||||
<string name="download_default_directory">Каталог за замовчуванням для завантаження манги</string>
|
||||
<string name="private_app_directory_warning">Цей каталог з усіма даними буде видалено, якщо ви видалите програму</string>
|
||||
<string name="available_pattern">Доступно %1$s</string>
|
||||
<string name="auto_double_foldable">Автоматичне двосторінкове розміщення на складному</string>
|
||||
<string name="pinned_sources_only">Тільки закріплені джерела</string>
|
||||
<string name="hide_empty_sources">Приховати порожні джерела</string>
|
||||
</resources>
|
||||
|
||||
@@ -866,4 +866,5 @@
|
||||
<string name="pull_bottom_no_next">到底了</string>
|
||||
<string name="enable_pull_gesture_title">启用推拉手势</string>
|
||||
<string name="enable_pull_gesture_summary">条漫模式下使用推拉手势切换章节</string>
|
||||
<string name="auto_double_foldable">折叠设备自动双页</string>
|
||||
</resources>
|
||||
|
||||
@@ -661,4 +661,5 @@
|
||||
<string name="download_new_chapters">下載新的漫畫章節</string>
|
||||
<string name="enable_all_sources">啟用所有漫畫來源</string>
|
||||
<string name="all_sources_enabled">所有漫畫來源已啟用</string>
|
||||
<string name="auto_double_foldable">摺疊設備自動雙頁</string>
|
||||
</resources>
|
||||
|
||||
@@ -210,6 +210,8 @@
|
||||
<string name="disabled">Disabled</string>
|
||||
<string name="reset_filter">Reset filter</string>
|
||||
<string name="enter_name">Enter name</string>
|
||||
<string name="pinned_sources_only">Pinned sources only</string>
|
||||
<string name="hide_empty_sources">Hide empty sources</string>
|
||||
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
|
||||
<string name="never">Never</string>
|
||||
<string name="only_using_wifi">Only on Wi-Fi</string>
|
||||
@@ -579,6 +581,7 @@
|
||||
<string name="none">None</string>
|
||||
<string name="config_reset_confirm">Reset settings to default values? This action cannot be undone.</string>
|
||||
<string name="use_two_pages_landscape">Use two pages layout on landscape orientation (beta)</string>
|
||||
<string name="auto_double_foldable">Auto Two-Page On Foldable</string>
|
||||
<string name="two_page_scroll_sensitivity">Two-Page Scroll Sensitivity</string>
|
||||
<string name="default_webtoon_zoom_out">Default webtoon zoom out</string>
|
||||
<string name="fullscreen_mode">Fullscreen mode</string>
|
||||
|
||||
@@ -49,6 +49,7 @@ viewpager2 = "1.1.0"
|
||||
webkit = "1.14.0"
|
||||
workRuntime = "2.10.5"
|
||||
workinspector = "1.2"
|
||||
window = "1.3.0"
|
||||
|
||||
[libraries]
|
||||
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }
|
||||
@@ -115,6 +116,7 @@ okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp
|
||||
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
|
||||
ssiv = { module = "com.github.KotatsuApp:subsampling-scale-image-view", version.ref = "ssiv" }
|
||||
workinspector = { module = "com.github.Koitharu:WorkInspector", version.ref = "workinspector" }
|
||||
androidx-window = { module = "androidx.window:window", version.ref = "window" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "gradle" }
|
||||
|
||||
Reference in New Issue
Block a user