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,14 +35,15 @@ 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,
|
||||
class ExceptionResolver private constructor(
|
||||
private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
@@ -56,10 +60,11 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
}
|
||||
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router()?.showErrorDialog(e, url)
|
||||
host.router.showErrorDialog(e, url)
|
||||
}
|
||||
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
|
||||
when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
@@ -71,7 +76,7 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||
|
||||
is ProxyConfigException -> {
|
||||
host.router()?.openProxySettings()
|
||||
host.router.openProxySettings()
|
||||
false
|
||||
}
|
||||
|
||||
@@ -80,6 +85,16 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
false
|
||||
}
|
||||
|
||||
is EmptyMangaException -> {
|
||||
when (e.reason) {
|
||||
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
|
||||
EmptyMangaReason.LOADING_ERROR -> Unit
|
||||
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
|
||||
else -> Unit
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
@@ -99,6 +114,7 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
|
||||
else -> false
|
||||
}
|
||||
}.await()
|
||||
|
||||
private suspend fun resolveBrowserAction(
|
||||
e: InteractiveActionRequiredException
|
||||
@@ -118,11 +134,11 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router()?.openBrowser(url, null, null)
|
||||
host.router.openBrowser(url, null, null)
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router()?.openAlternatives(manga)
|
||||
host.router.openAlternatives(manga)
|
||||
}
|
||||
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
@@ -130,7 +146,7 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = host.getContext() ?: return
|
||||
val ctx = host.context ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -147,27 +163,65 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
}.show()
|
||||
}
|
||||
|
||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||
getContext()?.apply(block)
|
||||
class Factory @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
|
||||
fun create(fragment: Fragment) = ExceptionResolver(
|
||||
host = Host.FragmentHost(fragment),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
|
||||
fun create(activity: FragmentActivity) = ExceptionResolver(
|
||||
host = Host.ActivityHost(activity),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Host.router(): AppRouter? = when (this) {
|
||||
is FragmentActivity -> router
|
||||
is Fragment -> router
|
||||
else -> null
|
||||
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
|
||||
|
||||
val context: Context?
|
||||
|
||||
val router: AppRouter
|
||||
|
||||
val fragmentManager: FragmentManager
|
||||
|
||||
inline fun withContext(block: Context.() -> Unit) {
|
||||
context?.apply(block)
|
||||
}
|
||||
|
||||
interface Host : ActivityResultCaller {
|
||||
class ActivityHost(val activity: FragmentActivity) : Host,
|
||||
ActivityResultCaller by activity,
|
||||
LifecycleOwner by activity {
|
||||
|
||||
fun getChildFragmentManager(): FragmentManager
|
||||
override val context: Context
|
||||
get() = activity
|
||||
|
||||
fun getContext(): Context?
|
||||
override val router: AppRouter
|
||||
get() = activity.router
|
||||
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = activity.supportFragmentManager
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
class FragmentHost(val fragment: Fragment) : Host,
|
||||
ActivityResultCaller by fragment {
|
||||
|
||||
fun create(host: Host): ExceptionResolver
|
||||
override val context: Context?
|
||||
get() = fragment.context
|
||||
|
||||
override val router: AppRouter
|
||||
get() = fragment.router
|
||||
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = fragment.childFragmentManager
|
||||
|
||||
override val lifecycle: Lifecycle
|
||||
get() = fragment.viewLifecycleOwner.lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -187,6 +241,12 @@ class ExceptionResolver @AssistedInject constructor(
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,12 @@ class AppRouter private constructor(
|
||||
startActivity(browserIntent(contextOrNull() ?: return, url, source, title))
|
||||
}
|
||||
|
||||
fun openBrowser(manga: Manga) = openBrowser(
|
||||
url = manga.publicUrl,
|
||||
source = manga.source,
|
||||
title = manga.title,
|
||||
)
|
||||
|
||||
fun openColorFilterConfig(manga: Manga, page: MangaPage) {
|
||||
startActivity(
|
||||
Intent(contextOrNull(), ColorFilterConfigActivity::class.java)
|
||||
|
||||
@@ -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
|
||||
@@ -103,6 +104,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
|
||||
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
|
||||
@@ -167,6 +169,8 @@ fun Throwable.getCauseUrl(): String? = when (this) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -50,6 +51,9 @@ data class MangaDetails(
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
|
||||
val isRestricted: Boolean
|
||||
get() = manga.state == MangaState.RESTRICTED
|
||||
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
|
||||
@@ -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
|
||||
@@ -110,6 +115,9 @@ class ReaderActivity :
|
||||
private lateinit var readerManager: ReaderManager
|
||||
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
|
||||
|
||||
// Tracks whether the foldable device is in an unfolded state (half-opened or flat)
|
||||
private var isFoldUnfolded: Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
||||
@@ -187,6 +195,11 @@ class ReaderActivity :
|
||||
viewBinding.zoomControl.isVisible = it
|
||||
}
|
||||
addMenuProvider(ReaderMenuProvider(viewModel))
|
||||
|
||||
observeWindowLayout()
|
||||
|
||||
// Apply initial double-mode considering foldable setting
|
||||
applyDoubleModeAuto()
|
||||
}
|
||||
|
||||
override fun getParentActivityIntent(): Intent? {
|
||||
@@ -341,7 +354,17 @@ class ReaderActivity :
|
||||
}
|
||||
|
||||
override fun onDoubleModeChanged(isEnabled: Boolean) {
|
||||
readerManager.setDoubleReaderMode(isEnabled)
|
||||
// Combine manual toggle with foldable auto setting
|
||||
applyDoubleModeAuto(isEnabled)
|
||||
}
|
||||
|
||||
private fun applyDoubleModeAuto(manualEnabled: Boolean? = null) {
|
||||
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
// Auto double-page on foldable when device is unfolded (half-opened or flat)
|
||||
val autoFoldable = settings.isReaderDoubleOnFoldable && isFoldUnfolded
|
||||
val manualLandscape = (manualEnabled ?: settings.isReaderDoubleOnLandscape) && isLandscape
|
||||
val autoEnabled = autoFoldable || manualLandscape
|
||||
readerManager.setDoubleReaderMode(autoEnabled)
|
||||
}
|
||||
|
||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||
@@ -521,6 +544,24 @@ class ReaderActivity :
|
||||
}
|
||||
}
|
||||
|
||||
// Observe foldable window layout to auto-enable double-page if configured
|
||||
private fun observeWindowLayout() {
|
||||
WindowInfoTracker.getOrCreate(this)
|
||||
.windowLayoutInfo(this)
|
||||
.onEach { info ->
|
||||
val fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
|
||||
val unfolded = when (fold?.state) {
|
||||
FoldingFeature.State.HALF_OPENED, FoldingFeature.State.FLAT -> true
|
||||
else -> false
|
||||
}
|
||||
if (unfolded != isFoldUnfolded) {
|
||||
isFoldUnfolded = unfolded
|
||||
applyDoubleModeAuto()
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun askForIncognitoMode() {
|
||||
buildAlertDialog(this, isCentered = true) {
|
||||
var dontAskAgain = false
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.nav.MangaIntent
|
||||
import org.koitharu.kotatsu.core.nav.ReaderIntent
|
||||
@@ -47,6 +48,7 @@ import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.details.domain.DetailsInteractor
|
||||
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase
|
||||
@@ -405,9 +407,11 @@ class ReaderViewModel @Inject constructor(
|
||||
private fun loadImpl() {
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) {
|
||||
var exception: Exception? = null
|
||||
var loadedDetails: MangaDetails? = null
|
||||
try {
|
||||
detailsLoadUseCase(intent, force = false)
|
||||
.collect { details ->
|
||||
loadedDetails = details
|
||||
if (mangaDetails.value == null) {
|
||||
mangaDetails.value = details
|
||||
}
|
||||
@@ -452,9 +456,28 @@ class ReaderViewModel @Inject constructor(
|
||||
exception = e.mergeWith(exception)
|
||||
}
|
||||
if (readingState.value == null) {
|
||||
onLoadingError.call(
|
||||
exception ?: IllegalStateException("Unable to load manga. This should never happen. Please report"),
|
||||
val loadedManga = loadedDetails // for smart cast
|
||||
if (loadedManga != null) {
|
||||
mangaDetails.value = loadedManga.filterChapters(selectedBranch.value)
|
||||
}
|
||||
val loadingError = when {
|
||||
exception != null -> exception
|
||||
loadedManga == null || !loadedManga.isLoaded -> null
|
||||
loadedManga.isRestricted -> EmptyMangaException(
|
||||
EmptyMangaReason.RESTRICTED,
|
||||
loadedManga.toManga(),
|
||||
null,
|
||||
)
|
||||
|
||||
loadedManga.allChapters.isEmpty() -> EmptyMangaException(
|
||||
EmptyMangaReason.NO_CHAPTERS,
|
||||
loadedManga.toManga(),
|
||||
null,
|
||||
)
|
||||
|
||||
else -> null
|
||||
} ?: IllegalStateException("Unable to load manga. This should never happen. Please report")
|
||||
onLoadingError.call(loadingError)
|
||||
} else exception?.let { e ->
|
||||
// manga has been loaded but error occurred
|
||||
errorEvent.call(e)
|
||||
|
||||
@@ -91,6 +91,8 @@ class ReaderConfigSheet :
|
||||
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)
|
||||
@@ -104,6 +106,7 @@ class ReaderConfigSheet :
|
||||
binding.buttonScrollTimer.setOnClickListener(this)
|
||||
binding.buttonBookmark.setOnClickListener(this)
|
||||
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
||||
binding.switchDoubleFoldable.setOnCheckedChangeListener(this)
|
||||
binding.sliderDoubleSensitivity.addOnChangeListener(this)
|
||||
|
||||
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
||||
@@ -182,6 +185,12 @@ class ReaderConfigSheet :
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +215,7 @@ class ReaderConfigSheet :
|
||||
}
|
||||
viewBinding?.run {
|
||||
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
||||
switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled
|
||||
adjustSensitivitySlider(withAnimation = true)
|
||||
}
|
||||
if (newMode == mode) {
|
||||
@@ -242,12 +252,18 @@ class ReaderConfigSheet :
|
||||
}
|
||||
|
||||
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
|
||||
val isSliderVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
|
||||
if (isSliderVisible != sliderDoubleSensitivity.isVisible && withAnimation) {
|
||||
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 = isSliderVisible
|
||||
textDoubleSensitivity.isVisible = isSliderVisible
|
||||
sliderDoubleSensitivity.isVisible = isSubOptionsVisible
|
||||
textDoubleSensitivity.isVisible = isSubOptionsVisible
|
||||
switchDoubleFoldable.isVisible = isSubOptionsVisible
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
@@ -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()
|
||||
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