Compare commits

...

26 Commits
v8.1 ... v8.1.2

Author SHA1 Message Date
Koitharu
313c2ab2bf Respect rounded corners for page numbers (#1360) 2025-04-08 09:09:36 +03:00
Koitharu
fe5d37f45e Fix hiding page loading indicator (close #1357) 2025-04-08 09:09:36 +03:00
Koitharu
92f6221ba0 Merge pull request #1367 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-04-08 09:03:34 +03:00
大王叫我来巡山
0590a0c56f Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.6% (811 of 814 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-04-07 18:01:57 +02:00
Koitharu
13ffc3a515 Translated using Weblate (Russian)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2025-04-07 18:01:55 +02:00
Nicola Bortoletto
74b36226f2 Translated using Weblate (Italian)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-04-07 18:01:53 +02:00
Boqirz
d501d0304a Translated using Weblate (Indonesian)
Currently translated at 98.7% (804 of 814 strings)

Co-authored-by: Boqirz <alveromodar@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-04-07 18:01:51 +02:00
Draken
1059933c87 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (814 of 814 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-04-07 18:01:49 +02:00
Koitharu
5fa58b931e Fix Cloudflare protection resolving 2025-04-06 18:10:34 +03:00
Koitharu
ddecc72de7 Update page state management in reader 2025-04-06 16:26:13 +03:00
Koitharu
d35a0c5e1e Allow to open reader when details is not loaded yet 2025-04-03 19:41:44 +03:00
Koitharu
340994ce77 Fix reader slider behavior 2025-04-03 13:59:26 +03:00
Koitharu
42b2f21c4d Fix bottom navigation insets #1341 2025-04-03 13:19:10 +03:00
Koitharu
e4b9da54dd Update parsers 2025-04-03 12:22:51 +03:00
Koitharu
ccc41314ae UI fixes 2025-04-03 12:21:06 +03:00
Koitharu
93eb6a19a5 Update page loading ui 2025-04-03 12:21:06 +03:00
Koitharu
e4f2e19d2c Merge pull request #1358 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-04-03 12:19:44 +03:00
Thinker
73a687c9a7 Translated using Weblate (Bengali)
Currently translated at 21.8% (178 of 814 strings)

Co-authored-by: Thinker <sayakkundu711@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
2025-04-03 03:49:15 +02:00
Draken
32ca3c11fa Translated using Weblate (Vietnamese)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Deivinni Silva
0d648dd188 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Frosted
86b7989c89 Translated using Weblate (Turkish)
Currently translated at 100.0% (814 of 814 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Alvoracz
01be6ab596 Translated using Weblate (Czech)
Currently translated at 99.3% (809 of 814 strings)

Co-authored-by: Alvoracz <sedlor@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
Infy's Tagalog Translations
a3d01e8d34 Translated using Weblate (Filipino)
Currently translated at 99.7% (812 of 814 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2025-04-03 03:49:14 +02:00
gekka
808bd47b64 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.6% (811 of 814 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-04-03 03:49:13 +02:00
Nicola Bortoletto
f4b506b26b Translated using Weblate (Italian)
Currently translated at 99.8% (813 of 814 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-04-03 03:49:13 +02:00
Koitharu
1f0d2e2039 Fix crash when open non-http url in browser 2025-03-31 10:19:31 +03:00
65 changed files with 821 additions and 754 deletions

View File

@@ -19,8 +19,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 1005
versionName = '8.1'
versionCode = 1008
versionName = '8.1.2'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {

View File

@@ -31,7 +31,7 @@ import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
import com.google.android.material.R as materialR
import androidx.appcompat.R as appcompatR
@AndroidEntryPoint
class AutoFixService : CoroutineIntentService() {
@@ -95,7 +95,7 @@ class AutoFixService : CoroutineIntentService() {
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction(
materialR.drawable.material_ic_clear_black_24dp,
appcompatR.drawable.abc_ic_clear_material,
applicationContext.getString(android.R.string.cancel),
jobContext.getCancelIntent(),
)

View File

@@ -6,10 +6,18 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import javax.inject.Inject
@AndroidEntryPoint
@@ -18,6 +26,9 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
@Inject
lateinit var proxyProvider: ProxyProvider
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) {
@@ -28,10 +39,21 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
onCreate2(savedInstanceState)
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
val userAgent = intent?.getStringExtra(AppRouter.KEY_USER_AGENT)?.nullIfEmpty()
?: repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
onCreate2(savedInstanceState, mangaSource, repository)
}
protected abstract fun onCreate2(savedInstanceState: Bundle?)
protected abstract fun onCreate2(
savedInstanceState: Bundle?,
source: MangaSource,
repository: ParserMangaRepository?
)
override fun onApplyWindowInsets(
v: View,

View File

@@ -8,30 +8,19 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint
class BrowserActivity : BaseBrowserActivity() {
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
override fun onCreate2(savedInstanceState: Bundle?) {
setDisplayHomeAsUp(true, true)
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this)
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
viewBinding.webView.webViewClient = BrowserClient(this)
lifecycleScope.launch {
try {
proxyProvider.applyWebViewConfig()

View File

@@ -3,10 +3,8 @@ package org.koitharu.kotatsu.browser
import android.graphics.Bitmap
import android.webkit.WebView
import androidx.webkit.WebViewClientCompat
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
open class BrowserClient(
private val proxyProvider: ProxyProvider,
private val callback: BrowserCallback
) : WebViewClientCompat() {

View File

@@ -22,9 +22,11 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import javax.inject.Inject
@AndroidEntryPoint
@@ -37,15 +39,14 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
private lateinit var cfClient: CloudFlareClient
override fun onCreate2(savedInstanceState: Bundle?) {
setDisplayHomeAsUp(true, true)
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
val url = intent?.dataString
if (url.isNullOrEmpty()) {
finishAfterTransition()
return
}
cfClient = CloudFlareClient(proxyProvider, cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.webViewClient = cfClient
lifecycleScope.launch {
try {
@@ -106,8 +107,7 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
setTitle(title)
supportActionBar?.subtitle =
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle }
}
private fun restartCheck() {

View File

@@ -4,17 +4,15 @@ import android.graphics.Bitmap
import android.webkit.WebView
import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
private const val LOOP_COUNTER = 3
class CloudFlareClient(
proxyProvider: ProxyProvider,
private val cookieJar: MutableCookieJar,
private val callback: CloudFlareCallback,
private val targetUrl: String,
) : BrowserClient(proxyProvider, callback) {
) : BrowserClient(callback) {
private val oldClearance = getClearance()
private var counter = 0

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
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.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
@@ -163,7 +164,7 @@ class ExceptionResolver @AssistedInject constructor(
is ScrobblerAuthRequiredException,
is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
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

View File

@@ -29,7 +29,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
import com.google.android.material.R as materialR
import androidx.appcompat.R as appcompatR
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
@@ -103,7 +103,7 @@ abstract class BaseActivity<B : ViewBinding> :
supportActionBar?.run {
setDisplayHomeAsUpEnabled(isEnabled)
if (showUpAsClose) {
setHomeAsUpIndicator(materialR.drawable.ic_clear_black_24)
setHomeAsUpIndicator(appcompatR.drawable.abc_ic_clear_material)
}
}
}

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.copyToClipboard
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.isReportable
import org.koitharu.kotatsu.core.util.ext.report
import org.koitharu.kotatsu.core.util.ext.requireSerializable
@@ -43,7 +44,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>(), Vie
super.onViewBindingCreated(binding, savedInstanceState)
binding.buttonBrowser.setOnClickListener(this)
binding.textViewSummary.text = exception.message
val isUrlAvailable = !exception.getCauseUrl().isNullOrEmpty()
val isUrlAvailable = exception.getCauseUrl()?.isHttpUrl() == true
binding.buttonBrowser.isVisible = isUrlAvailable
binding.textViewBrowser.isVisible = isUrlAvailable
binding.textViewDescription.setTextAndVisible(

View File

@@ -8,7 +8,7 @@ import android.view.animation.DecelerateInterpolator
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.navigation.NavigationBarView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.measureHeight
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.measureHeight
class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
context: Context? = null,
attrs: AttributeSet? = null,
) : CoordinatorLayout.Behavior<BottomNavigationView>(context, attrs) {
) : CoordinatorLayout.Behavior<NavigationBarView>(context, attrs) {
@ViewCompat.NestedScrollType
private var lastStartedType: Int = 0
@@ -34,13 +34,13 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
}
}
override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean {
override fun layoutDependsOn(parent: CoordinatorLayout, child: NavigationBarView, dependency: View): Boolean {
return dependency is AppBarLayout
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: BottomNavigationView,
child: NavigationBarView,
dependency: View,
): Boolean {
val appBarSize = dependency.measureHeight()
@@ -54,7 +54,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView,
child: NavigationBarView,
directTargetChild: View,
target: View,
axes: Int,
@@ -70,7 +70,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
override fun onNestedPreScroll(
coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView,
child: NavigationBarView,
target: View,
dx: Int,
dy: Int,
@@ -85,7 +85,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
override fun onStopNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: BottomNavigationView,
child: NavigationBarView,
target: View,
type: Int,
) {
@@ -94,7 +94,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
}
}
private fun animateBottomNavigationVisibility(child: BottomNavigationView, isVisible: Boolean) {
private fun animateBottomNavigationVisibility(child: NavigationBarView, isVisible: Boolean) {
offsetAnimator?.cancel()
offsetAnimator = ValueAnimator().apply {
interpolator = DecelerateInterpolator()

View File

@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.core.ui.widgets
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.TimeInterpolator
import android.annotation.SuppressLint
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ViewPropertyAnimator
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
@@ -15,9 +17,11 @@ import androidx.core.view.isVisible
import androidx.customview.view.AbsSavedState
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.bottomnavigation.BottomNavigationMenuView
import com.google.android.material.navigation.NavigationBarView
import org.koitharu.kotatsu.core.util.ext.applySystemAnimatorScale
import org.koitharu.kotatsu.core.util.ext.measureHeight
import kotlin.math.max
import com.google.android.material.R as materialR
private const val STATE_DOWN = 1
@@ -26,12 +30,14 @@ private const val STATE_UP = 2
private const val SLIDE_UP_ANIMATION_DURATION = 225L
private const val SLIDE_DOWN_ANIMATION_DURATION = 175L
private const val MAX_ITEM_COUNT = 6
class SlidingBottomNavigationView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = materialR.attr.bottomNavigationStyle,
@StyleRes defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView,
) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes),
) : NavigationBarView(context, attrs, defStyleAttr, defStyleRes),
CoordinatorLayout.AttachedBehavior {
private var currentAnimator: ViewPropertyAnimator? = null
@@ -55,6 +61,49 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
return behavior
}
/** From BottomNavigationView **/
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
super.onTouchEvent(event)
// Consume all events to avoid views under the BottomNavigationView from receiving touch events.
return true
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val minHeightSpec = makeMinHeightSpec(heightMeasureSpec)
super.onMeasure(widthMeasureSpec, minHeightSpec)
if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) {
setMeasuredDimension(
measuredWidth,
max(
measuredHeight,
suggestedMinimumHeight + paddingTop + paddingBottom,
),
)
}
}
private fun makeMinHeightSpec(measureSpec: Int): Int {
var minHeight = suggestedMinimumHeight
if (MeasureSpec.getMode(measureSpec) != MeasureSpec.EXACTLY && minHeight > 0) {
minHeight += paddingTop + paddingBottom
return MeasureSpec.makeMeasureSpec(
max(MeasureSpec.getSize(measureSpec), minHeight), MeasureSpec.AT_MOST,
)
}
return measureSpec
}
override fun getMaxItemCount(): Int = MAX_ITEM_COUNT
@SuppressLint("RestrictedApi")
override fun createNavigationBarMenuView(context: Context) = BottomNavigationMenuView(context)
/** End **/
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return SavedState(superState, currentState, translationY)

View File

@@ -1,28 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.view.View
import android.view.ViewTreeObserver
/**
* ProgressIndicator become INVISIBLE instead of GONE by hide() call.
* It`s final so we need this workaround
*/
class GoneOnInvisibleListener(
private val view: View,
) : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (view.visibility == View.INVISIBLE) {
view.visibility = View.GONE
}
}
fun attach() {
view.viewTreeObserver.addOnGlobalLayoutListener(this)
onGlobalLayout()
}
fun detach() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
}

View File

@@ -61,6 +61,8 @@ inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<
return map { list -> list.map(transform) }
}
fun <T> Flow<T>.throttle(timeoutMillis: Long): Flow<T> = throttle { timeoutMillis }
fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
var lastEmittedAt = 0L
return transformLatest { value ->

View File

@@ -10,7 +10,6 @@ import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
@@ -155,9 +154,9 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
if (value) {
if (!isVisible) show()
show()
} else {
if (isVisible) hide()
hide()
}
}

View File

@@ -26,6 +26,7 @@ class DetailsErrorObserver(
override suspend fun emit(value: Throwable) {
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
snackbar.setAnchorView(activity.viewBinding.containerBottomSheet)
if (value is NotFoundException || value is UnsupportedSourceException) {
snackbar.duration = Snackbar.LENGTH_INDEFINITE
}

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
class DetailsMenuProvider(
private val activity: FragmentActivity,
@@ -36,7 +37,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_share).isVisible = manga != null && AppRouter.isShareSupported(manga)
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource
menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource
menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource
menu.findItem(R.id.action_browser).isVisible = manga?.publicUrl?.isHttpUrl() == true
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable

View File

@@ -125,15 +125,16 @@ class ReadButtonDelegate(
}
private fun onHistoryChanged(isLoading: Boolean, info: HistoryInfo) {
val isChaptersLoading = isLoading && (info.totalChapters <= 0 || info.isChapterMissing)
buttonRead.setText(
when {
isLoading -> R.string.loading_
isChaptersLoading -> R.string.loading_
info.isIncognitoMode -> R.string.incognito
info.canContinue -> R.string._continue
else -> R.string.read
},
)
splitButton.isEnabled = !isLoading && info.isValid
splitButton.isEnabled = !isChaptersLoading && info.isValid
}
private fun Menu.populateBranchList() {

View File

@@ -36,7 +36,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.UUID
import com.google.android.material.R as materialR
import androidx.appcompat.R as appcompatR
private const val CHANNEL_ID_DEFAULT = "download"
private const val CHANNEL_ID_SILENT = "download_bg"
@@ -70,7 +70,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
private val actionCancel by lazy {
NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
appcompatR.drawable.abc_ic_clear_material,
context.getString(android.R.string.cancel),
workManager.createCancelPendingIntent(uuid),
)

View File

@@ -72,7 +72,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService
import javax.inject.Inject
import com.google.android.material.R as materialR
import androidx.appcompat.R as appcompatR
private const val TAG_SEARCH = "search"
@@ -231,6 +231,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
topMargin = barsInsets.top
bottomMargin = barsInsets.bottom
}
updateContainerBottomMargin()
return insets.consume(v, typeMask, start = viewBinding.navRail != null)
}
@@ -429,9 +430,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
supportActionBar?.apply {
setHomeAsUpIndicator(
if (isOpened) {
materialR.drawable.ic_arrow_back_black_24
appcompatR.drawable.abc_ic_ab_back_material
} else {
materialR.drawable.ic_search_black_24
appcompatR.drawable.abc_ic_search_api_material
},
)
setHomeActionContentDescription(

View File

@@ -127,8 +127,10 @@ class ReaderActionsView @JvmOverloads constructor(
}
override fun onStartTrackingTouch(slider: Slider) {
isSliderChanged = false
isSliderTracking = true
if (!isSliderTracking) {
isSliderChanged = false
isSliderTracking = true
}
}
override fun onStopTrackingTouch(slider: Slider) {

View File

@@ -105,7 +105,7 @@ class ReaderActivity :
super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater))
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
setDisplayHomeAsUp(true, false)
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
touchHelper = TapGridDispatcher(this, this)
scrollTimer = scrollTimerFactory.create(this, this)
pageSaveHelper = pageSaveHelperFactory.create(this)
@@ -146,7 +146,7 @@ class ReaderActivity :
.setAnchorView(viewBinding.toolbarDocked)
.show()
}
viewModel.readerSettings.observe(this) {
viewModel.readerSettingsProducer.observe(this) {
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
}
viewModel.isZoomControlsEnabled.observe(this) {

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
@@ -85,6 +86,7 @@ class ReaderViewModel @Inject constructor(
interactor: DetailsInteractor,
deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
downloadScheduler: DownloadWorker.Scheduler,
readerSettingsProducerFactory: ReaderSettings.Producer.Factory,
) : ChaptersPagesViewModel(
settings = settings,
interactor = interactor,
@@ -170,12 +172,8 @@ class ReaderViewModel @Inject constructor(
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
val readerSettings = ReaderSettings(
parentScope = viewModelScope,
settings = settings,
colorFilterFlow = manga.flatMapLatest {
if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
val readerSettingsProducer = readerSettingsProducerFactory.create(
manga.mapNotNull { it?.id },
)
val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT }

View File

@@ -1,66 +1,69 @@
package org.koitharu.kotatsu.reader.ui.config
import android.content.SharedPreferences
import android.graphics.Bitmap
import android.view.View
import androidx.annotation.CheckResult
import androidx.lifecycle.MediatorLiveData
import androidx.collection.scatterSetOf
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import kotlinx.coroutines.CoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderBackground
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.MediatorStateFlow
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
class ReaderSettings(
private val parentScope: CoroutineScope,
private val settings: AppSettings,
private val colorFilterFlow: StateFlow<ReaderColorFilter?>,
) : MediatorLiveData<ReaderSettings>() {
data class ReaderSettings(
val zoomMode: ZoomMode,
val background: ReaderBackground,
val colorFilter: ReaderColorFilter?,
val isReaderOptimizationEnabled: Boolean,
val bitmapConfig: Bitmap.Config,
val isPagesNumbersEnabled: Boolean,
val isPagesCropEnabledStandard: Boolean,
val isPagesCropEnabledWebtoon: Boolean,
) {
private val internalObserver = InternalObserver()
private var collectJob: Job? = null
val zoomMode: ZoomMode
get() = settings.zoomMode
val background: ReaderBackground
get() = settings.readerBackground
val colorFilter: ReaderColorFilter?
get() = colorFilterFlow.value?.takeUnless { it.isEmpty } ?: settings.readerColorFilter
val isReaderOptimizationEnabled: Boolean
get() = settings.isReaderOptimizationEnabled
val bitmapConfig: Bitmap.Config
get() = if (settings.is32BitColorsEnabled) {
private constructor(settings: AppSettings, colorFilterOverride: ReaderColorFilter?) : this(
zoomMode = settings.zoomMode,
background = settings.readerBackground,
colorFilter = colorFilterOverride?.takeUnless { it.isEmpty } ?: settings.readerColorFilter,
isReaderOptimizationEnabled = settings.isReaderOptimizationEnabled,
bitmapConfig = if (settings.is32BitColorsEnabled) {
Bitmap.Config.ARGB_8888
} else {
Bitmap.Config.RGB_565
}
val isPagesNumbersEnabled: Boolean
get() = settings.isPagesNumbersEnabled
},
isPagesNumbersEnabled = settings.isPagesNumbersEnabled,
isPagesCropEnabledStandard = settings.isPagesCropEnabled(ReaderMode.STANDARD),
isPagesCropEnabledWebtoon = settings.isPagesCropEnabled(ReaderMode.WEBTOON),
)
fun applyBackground(view: View) {
view.background = background.resolve(view.context)
}
fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled(
if (isWebtoon) ReaderMode.WEBTOON else ReaderMode.STANDARD,
)
fun isPagesCropEnabled(isWebtoon: Boolean) = if (isWebtoon) {
isPagesCropEnabledWebtoon
} else {
isPagesCropEnabledStandard
}
@CheckResult
fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean {
@@ -78,33 +81,13 @@ class ReaderSettings(
}
}
override fun onInactive() {
super.onInactive()
settings.unsubscribe(internalObserver)
collectJob?.cancel()
collectJob = null
}
class Producer @AssistedInject constructor(
@Assisted private val mangaId: Flow<Long>,
private val settings: AppSettings,
private val mangaDataRepository: MangaDataRepository,
) : MediatorStateFlow<ReaderSettings>(ReaderSettings(settings, null)) {
override fun onActive() {
super.onActive()
settings.subscribe(internalObserver)
collectJob?.cancel()
collectJob = parentScope.launch {
colorFilterFlow.collect(internalObserver)
}
}
override fun getValue() = this
private fun notifyChanged() {
value = value
}
private inner class InternalObserver :
FlowCollector<ReaderColorFilter?>,
SharedPreferences.OnSharedPreferenceChangeListener {
private val settingsKeys = setOf(
private val settingsKeys = scatterSetOf(
AppSettings.KEY_ZOOM_MODE,
AppSettings.KEY_PAGES_NUMBERS,
AppSettings.KEY_READER_BACKGROUND,
@@ -114,18 +97,38 @@ class ReaderSettings(
AppSettings.KEY_CF_BRIGHTNESS,
AppSettings.KEY_CF_INVERTED,
AppSettings.KEY_CF_GRAYSCALE,
AppSettings.KEY_READER_CROP,
)
private var job: Job? = null
override suspend fun emit(value: ReaderColorFilter?) {
withContext(Dispatchers.Main.immediate) {
notifyChanged()
override fun onActive() {
assert(job?.isActive != true)
job?.cancel()
job = processLifecycleScope.launch(Dispatchers.Default) {
observeImpl()
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key in settingsKeys) {
notifyChanged()
override fun onInactive() {
job?.cancel()
job = null
}
private suspend fun observeImpl() {
combine(
mangaId.flatMapLatest { mangaDataRepository.observeColorFilter(it) },
settings.observe().filter { x -> x == null || x in settingsKeys }.onStart { emit(null) },
) { mangaCf, settingsKey ->
ReaderSettings(settings, mangaCf)
}.collect {
publishValue(it)
}
}
@AssistedFactory
interface Factory {
fun create(mangaId: Flow<Long>): Producer
}
}
}

View File

@@ -1,40 +1,58 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.content.ComponentCallbacks2
import android.content.ComponentCallbacks2.TRIM_MEMORY_COMPLETE
import android.content.Context
import android.content.res.Configuration
import android.view.View
import androidx.annotation.CallSuper
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State
import org.koitharu.kotatsu.reader.ui.pager.vm.PageState
import org.koitharu.kotatsu.reader.ui.pager.vm.PageViewModel
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder
abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B,
loader: PageLoader,
protected val settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
lifecycleOwner: LifecycleOwner,
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback {
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), DefaultOnImageEventListener, ComponentCallbacks2 {
@Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(
protected val viewModel = PageViewModel(
loader = loader,
readerSettings = settings,
callback = this,
settingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
isWebtoon = this is WebtoonHolder,
)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
protected abstract val ssiv: SubsamplingScaleImageView
protected val settings: ReaderSettings
get() = viewModel.settingsProducer.value
val context: Context
get() = itemView.context
@@ -42,51 +60,140 @@ abstract class BasePageHolder<B : ViewBinding>(
var boundData: ReaderPage? = null
private set
override fun onConfigChanged() {
settings.applyBackground(itemView)
init {
lifecycleScope.launch(Dispatchers.Main) {
ssiv.bindToLifecycle(this@BasePageHolder)
ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
ssiv.addOnImageEventListener(viewModel)
ssiv.addOnImageEventListener(this@BasePageHolder)
}
val clickListener = View.OnClickListener { v ->
when (v.id) {
R.id.button_retry -> viewModel.retry(
page = boundData?.toMangaPage() ?: return@OnClickListener,
isFromUser = true,
)
R.id.button_error_details -> viewModel.showErrorDetails(boundData?.url)
}
}
bindingInfo.buttonRetry.setOnClickListener(clickListener)
bindingInfo.buttonErrorDetails.setOnClickListener(clickListener)
}
fun requireData(): ReaderPage {
return checkNotNull(boundData) { "Calling requireData() before bind()" }
@CallSuper
protected open fun onConfigChanged(settings: ReaderSettings) {
settings.applyBackground(itemView)
if (settings.applyBitmapConfig(ssiv)) {
reloadImage()
} else if (viewModel.state.value is PageState.Shown) {
onReady()
}
ssiv.applyDownSampling(isResumed())
}
fun reloadImage() {
val source = (viewModel.state.value as? PageState.Shown)?.source ?: return
ssiv.setImage(source)
}
fun bind(data: ReaderPage) {
boundData = data
viewModel.onBind(data.toMangaPage())
onBind(data)
}
protected abstract fun onBind(data: ReaderPage)
@CallSuper
protected open fun onBind(data: ReaderPage) = Unit
override fun onCreate() {
super.onCreate()
context.registerComponentCallbacks(delegate)
context.registerComponentCallbacks(this)
viewModel.state.observe(this, ::onStateChanged)
viewModel.settingsProducer.observe(this, ::onConfigChanged)
}
override fun onResume() {
super.onResume()
if (delegate.state == State.ERROR && !delegate.isLoading()) {
boundData?.let { delegate.retry(it.toMangaPage(), isFromUser = false) }
ssiv.applyDownSampling(isForeground = true)
if (viewModel.state.value is PageState.Error && !viewModel.isLoading()) {
boundData?.let { viewModel.retry(it.toMangaPage(), isFromUser = false) }
}
}
override fun onPause() {
super.onPause()
ssiv.applyDownSampling(isForeground = false)
}
override fun onDestroy() {
context.unregisterComponentCallbacks(delegate)
context.unregisterComponentCallbacks(this)
super.onDestroy()
}
@CallSuper
open fun onAttachedToWindow() {
delegate.onAttachedToWindow()
}
open fun onAttachedToWindow() = Unit
@CallSuper
open fun onDetachedFromWindow() {
delegate.onDetachedFromWindow()
}
open fun onDetachedFromWindow() = Unit
@CallSuper
open fun onRecycled() {
delegate.onRecycle()
viewModel.onRecycle()
ssiv.recycle()
}
override fun onTrimMemory(level: Int) {
// TODO
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
@Deprecated("Deprecated in Java")
final override fun onLowMemory() = onTrimMemory(TRIM_MEMORY_COMPLETE)
protected open fun onStateChanged(state: PageState) {
bindingInfo.layoutError.isVisible = state is PageState.Error
bindingInfo.progressBar.isGone = state.isFinalState()
bindingInfo.textViewStatus.isGone = state.isFinalState()
val progress = (state as? PageState.Loading)?.progress ?: -1
if (progress in 0..100) {
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
bindingInfo.textViewStatus.text = context.getString(R.string.percent_string_pattern, progress.toString())
} else {
bindingInfo.progressBar.isIndeterminate = true
bindingInfo.textViewStatus.setText(R.string.loading_)
}
when (state) {
is PageState.Converting -> {
bindingInfo.textViewStatus.setText(R.string.processing_)
}
is PageState.Empty -> Unit
is PageState.Error -> {
val e = state.error
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
is PageState.Loaded -> {
bindingInfo.textViewStatus.setText(R.string.processing_)
ssiv.setImage(state.source)
}
is PageState.Loading -> {
if (state.preview != null && ssiv.getState() == null) {
ssiv.setImage(state.preview)
}
}
is PageState.Shown -> Unit
}
}
protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) {

View File

@@ -142,7 +142,7 @@ abstract class BasePagerReaderFragment : BaseReaderFragment<FragmentReaderPagerB
override fun onCreateAdapter(): BaseReaderAdapter<*> = PagesAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -15,7 +15,7 @@ import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader,
private val readerSettings: ReaderSettings,
private val readerSettingsProducer: ReaderSettings.Producer,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter<H>() {
@@ -58,7 +58,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
final override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver)
): H = onCreateViewHolder(parent, loader, readerSettingsProducer, networkState, exceptionResolver)
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine { cont ->
differ.submitList(items) {
@@ -69,7 +69,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
protected abstract fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
): H

View File

@@ -1,262 +0,0 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import android.graphics.Rect
import android.net.Uri
import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import com.davemorrissey.labs.subscaleview.ImageSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import java.io.IOException
class PageHolderDelegate(
private val loader: PageLoader,
private val readerSettings: ReaderSettings,
private val callback: Callback,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
private val isWebtoon: Boolean,
) : DefaultOnImageEventListener, Observer<ReaderSettings>, ComponentCallbacks2 {
private val scope = loader.loaderScope + Dispatchers.Main.immediate
var state = State.EMPTY
private set
private var job: Job? = null
private var uri: Uri? = null
private var cachedBounds: Rect? = null
private var error: Throwable? = null
init {
scope.launch(Dispatchers.Main) { // the same as post() -- wait until child fields init
callback.onConfigChanged()
}
}
fun isLoading() = job?.isActive == true
fun onBind(page: MangaPage) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
doLoad(page, force = false)
}
}
fun retry(page: MangaPage, isFromUser: Boolean) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
val e = error
if (e != null && ExceptionResolver.canResolve(e)) {
if (!isFromUser) {
return@launch
}
exceptionResolver.resolve(e)
}
doLoad(page, force = true)
}
}
fun showErrorDetails(url: String?) {
val e = error ?: return
exceptionResolver.showErrorDetails(e, url)
}
fun onAttachedToWindow() {
readerSettings.observeForever(this)
}
fun onDetachedFromWindow() {
readerSettings.removeObserver(this)
}
fun onRecycle() {
state = State.EMPTY
uri = null
cachedBounds = null
error = null
job?.cancel()
}
fun reload() {
if (state == State.SHOWN) {
uri?.let {
callback.onImageReady(it.toImageSource(cachedBounds))
}
}
}
override fun onReady() {
if (state >= State.LOADED) {
state = State.SHOWING
error = null
callback.onImageShowing(readerSettings, isPreview = false)
} else if (state == State.LOADING_WITH_PREVIEW) {
callback.onImageShowing(readerSettings, isPreview = true)
}
}
override fun onImageLoaded() {
if (state >= State.LOADED) {
state = State.SHOWN
error = null
callback.onImageShown()
}
}
override fun onImageLoadError(e: Throwable) {
e.printStackTraceDebug()
if (state < State.LOADED) {
// ignore preview error
return
}
val uri = this.uri
error = e
if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) {
tryConvert(uri, e)
} else {
state = State.ERROR
callback.onError(e)
}
}
override fun onChanged(value: ReaderSettings) {
if (state == State.SHOWN) {
callback.onImageShowing(readerSettings, isPreview = false)
}
callback.onConfigChanged()
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
@Suppress("OVERRIDE_DEPRECATION")
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
callback.onTrimMemory()
}
private fun tryConvert(uri: Uri, e: Exception) {
val prevJob = job
job = scope.launch {
prevJob?.join()
state = State.CONVERTING
try {
val newUri = loader.convertBimap(uri)
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(newUri)
} else {
null
}
state = State.CONVERTED
callback.onImageReady(newUri.toImageSource(cachedBounds))
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) {
e2.printStackTrace()
e.addSuppressed(e2)
state = State.ERROR
callback.onError(e)
}
}
}
private suspend fun doLoad(data: MangaPage, force: Boolean) = coroutineScope {
state = State.LOADING
error = null
callback.onLoadingStarted()
launch {
val preview = loader.loadPreview(data) ?: return@launch
if (state == State.LOADING) {
state = State.LOADING_WITH_PREVIEW
callback.onPreviewReady(preview)
}
}
try {
val task = withContext(Dispatchers.Default) {
loader.loadPageAsync(data, force)
}
val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await()
progressObserver.cancelAndJoin()
uri = file
state = State.LOADED
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(checkNotNull(uri))
} else {
null
}
callback.onImageReady(checkNotNull(uri).toImageSource(cachedBounds))
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
state = State.ERROR
error = e
callback.onError(e)
if (e is IOException && !networkState.value) {
networkState.awaitForConnection()
retry(data, isFromUser = false)
}
}
}
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
.debounce(250)
.onEach { callback.onProgressChanged((100 * it).toInt()) }
.launchIn(scope)
private fun Uri.toImageSource(bounds: Rect?): ImageSource {
val source = ImageSource.uri(this)
return if (bounds != null) {
source.region(bounds)
} else {
source
}
}
enum class State {
EMPTY, LOADING, LOADING_WITH_PREVIEW, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
}
interface Callback {
fun onLoadingStarted()
fun onError(e: Throwable)
fun onPreviewReady(source: ImageSource)
fun onImageReady(source: ImageSource)
fun onImageShowing(settings: ReaderSettings, isPreview: Boolean)
fun onImageShown()
fun onProgressChanged(progress: Int)
fun onConfigChanged()
fun onTrimMemory()
}
}

View File

@@ -17,10 +17,17 @@ class DoublePageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
) : PageHolder(
owner = owner,
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
) {
private val isEven: Boolean
get() = bindingAdapterPosition and 1 == 0
@@ -35,7 +42,7 @@ class DoublePageHolder(
.gravity = (if (isEven) Gravity.START else Gravity.END) or Gravity.BOTTOM
}
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
override fun onReady() {
with(binding.ssiv) {
maxScale = 2f * maxOf(
width / sWidth.toFloat(),

View File

@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class DoublePagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<DoublePageHolder>(loader, settings, networkState, exceptionResolver) {
) : BaseReaderAdapter<DoublePageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = DoublePageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -90,7 +90,7 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
override fun onCreateAdapter() = DoublePagesAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -1,13 +1,21 @@
package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.graphics.PointF
import android.os.Build
import android.view.Gravity
import android.view.RoundedCorner
import android.view.ViewGroup
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.isRtl
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -17,17 +25,24 @@ class ReversedPageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
) : PageHolder(
owner = owner,
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
) {
init {
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
.gravity = Gravity.START or Gravity.BOTTOM
}
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
override fun onReady() {
with(binding.ssiv) {
maxScale = 2f * maxOf(
width / sWidth.toFloat(),

View File

@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class ReversedPagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) {
) : BaseReaderAdapter<ReversedPageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = ReversedPageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -19,7 +19,7 @@ class ReversedReaderFragment : BasePagerReaderFragment() {
override fun onCreateAdapter() = ReversedPagesAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -2,22 +2,28 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.annotation.SuppressLint
import android.graphics.PointF
import android.os.Build
import android.view.Gravity
import android.view.RoundedCorner
import android.view.View
import android.view.WindowInsets
import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
@@ -27,77 +33,48 @@ open class PageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
View.OnClickListener,
ZoomControl.ZoomControlListener {
) : BasePageHolder<ItemPageBinding>(
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
lifecycleOwner = owner,
), ZoomControl.ZoomControlListener, OnApplyWindowInsetsListener {
override val ssiv = binding.ssiv
init {
binding.ssiv.bindToLifecycle(owner)
binding.ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
binding.ssiv.addOnImageEventListener(delegate)
@Suppress("LeakingThis")
bindingInfo.buttonRetry.setOnClickListener(this)
@Suppress("LeakingThis")
bindingInfo.buttonErrorDetails.setOnClickListener(this)
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
}
override fun onResume() {
super.onResume()
binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
super.onConfigChanged()
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
override fun onApplyWindowInsets(
v: View,
insets: WindowInsetsCompat
): WindowInsetsCompat {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
insets.toWindowInsets()?.let {
applyRoundedCorners(it)
}
}
binding.ssiv.applyDownSampling(isResumed())
return insets
}
override fun onConfigChanged(settings: ReaderSettings) {
super.onConfigChanged(settings)
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
}
@SuppressLint("SetTextI18n")
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
super.onBind(data)
binding.textViewNumber.text = (data.index + 1).toString()
}
override fun onRecycled() {
super.onRecycled()
binding.ssiv.recycle()
}
override fun onLoadingStarted() {
bindingInfo.layoutError.isVisible = false
bindingInfo.progressBar.show()
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
} else {
bindingInfo.progressBar.isIndeterminate = true
}
}
override fun onPreviewReady(source: ImageSource) {
binding.ssiv.setImage(source)
}
override fun onImageReady(source: ImageSource) {
binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
override fun onReady() {
binding.ssiv.maxScale = 2f * maxOf(
binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
@@ -137,31 +114,6 @@ open class PageHolder(
}
}
override fun onImageShown() {
bindingInfo.progressBar.hide()
}
override fun onTrimMemory() {
// TODO https://developer.android.com/topic/performance/memory
}
final override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
override fun onZoomIn() {
scaleBy(1.2f)
}
@@ -170,6 +122,29 @@ open class PageHolder(
scaleBy(0.8f)
}
@SuppressLint("RtlHardcoded")
@RequiresApi(Build.VERSION_CODES.S)
protected open fun applyRoundedCorners(insets: WindowInsets) {
binding.textViewNumber.updateLayoutParams<FrameLayout.LayoutParams> {
val baseMargin = context.resources.getDimensionPixelOffset(R.dimen.margin_small)
val absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection)
val corner = when {
absoluteGravity and Gravity.LEFT == Gravity.LEFT -> {
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)
}
absoluteGravity and Gravity.RIGHT == Gravity.RIGHT -> {
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)
}
else -> {
null
}
}
setMargins(baseMargin + (corner?.radius ?: 0))
}
}
private fun scaleBy(factor: Float) {
val ssiv = binding.ssiv
val center = ssiv.getCenter() ?: return

View File

@@ -13,22 +13,27 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class PagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<PageHolder>(loader, settings, networkState, exceptionResolver) {
) : BaseReaderAdapter<PageHolder>(
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = PageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.reader.ui.pager.vm
import com.davemorrissey.labs.subscaleview.ImageSource
sealed class PageState {
data object Empty : PageState()
data class Loading(
val preview: ImageSource?,
val progress: Int,
) : PageState()
data class Loaded(
val source: ImageSource,
val isConverted: Boolean,
) : PageState()
class Converting() : PageState()
data class Shown(
val source: ImageSource,
val isConverted: Boolean,
) : PageState()
data class Error(
val error: Throwable,
) : PageState()
fun isFinalState(): Boolean = this is Error || this is Shown
}

View File

@@ -0,0 +1,187 @@
package org.koitharu.kotatsu.reader.ui.pager.vm
import android.graphics.Rect
import android.net.Uri
import androidx.annotation.WorkerThread
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import com.davemorrissey.labs.subscaleview.ImageSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.throttle
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
class PageViewModel(
private val loader: PageLoader,
val settingsProducer: ReaderSettings.Producer,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
private val isWebtoon: Boolean,
) : DefaultOnImageEventListener {
private val scope = loader.loaderScope + Dispatchers.Main.immediate
private var job: Job? = null
private var cachedBounds: Rect? = null
val state = MutableStateFlow<PageState>(PageState.Empty)
fun isLoading() = job?.isActive == true
fun onBind(page: MangaPage) {
val prevJob = job
job = scope.launch(Dispatchers.Default) {
prevJob?.cancelAndJoin()
doLoad(page, force = false)
}
}
fun retry(page: MangaPage, isFromUser: Boolean) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
val e = (state.value as? PageState.Error)?.error
if (e != null && ExceptionResolver.canResolve(e)) {
if (isFromUser) {
exceptionResolver.resolve(e)
}
}
withContext(Dispatchers.Default) {
doLoad(page, force = true)
}
}
}
fun showErrorDetails(url: String?) {
val e = (state.value as? PageState.Error)?.error ?: return
exceptionResolver.showErrorDetails(e, url)
}
fun onRecycle() {
state.value = PageState.Empty
cachedBounds = null
job?.cancel()
}
override fun onImageLoaded() {
state.update { currentState ->
if (currentState is PageState.Loaded) {
PageState.Shown(currentState.source, currentState.isConverted)
} else {
currentState
}
}
}
override fun onImageLoadError(e: Throwable) {
e.printStackTraceDebug()
state.update { currentState ->
if (currentState is PageState.Loaded) {
val uri = (currentState.source as? ImageSource.Uri)?.uri
if (!currentState.isConverted && uri != null && e is IOException) {
tryConvert(uri, e)
PageState.Converting()
} else {
PageState.Error(e)
}
} else {
currentState
}
}
}
private fun tryConvert(uri: Uri, e: Exception) {
val prevJob = job
job = scope.launch(Dispatchers.Default) {
prevJob?.join()
state.value = PageState.Converting()
try {
val newUri = loader.convertBimap(uri)
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(newUri)
} else {
null
}
state.value = PageState.Loaded(uri.toImageSource(cachedBounds), isConverted = true)
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) {
e2.printStackTrace()
e.addSuppressed(e2)
state.value = PageState.Error(e)
}
}
}
@WorkerThread
private suspend fun doLoad(data: MangaPage, force: Boolean) = coroutineScope {
state.value = PageState.Loading(null, -1)
val previewJob = launch {
val preview = loader.loadPreview(data) ?: return@launch
state.update {
if (it is PageState.Loading) it.copy(preview = preview) else it
}
}
try {
val task = loader.loadPageAsync(data, force)
val progressObserver = observeProgress(this, task.progressAsFlow())
val uri = task.await()
progressObserver.cancelAndJoin()
previewJob.cancel()
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(uri)
} else {
null
}
state.value = PageState.Loaded(uri.toImageSource(cachedBounds), isConverted = false)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
state.value = PageState.Error(e)
if (e is IOException && !networkState.value) {
networkState.awaitForConnection()
retry(data, isFromUser = false)
}
}
}
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
.throttle(250)
.onEach {
val progressValue = (100 * it).toInt()
state.update { currentState ->
if (currentState is PageState.Loading) {
currentState.copy(progress = progressValue)
} else {
currentState
}
}
}.launchIn(scope)
private fun Uri.toImageSource(bounds: Rect?): ImageSource {
val source = ImageSource.uri(this)
return if (bounds != null) {
source.region(bounds)
} else {
source
}
}
}

View File

@@ -13,15 +13,15 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class WebtoonAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) {
) : BaseReaderAdapter<WebtoonHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = WebtoonHolder(
@@ -32,7 +32,7 @@ class WebtoonAdapter(
false,
),
loader = loader,
settings = settings,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -1,101 +1,39 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
class WebtoonHolder(
owner: LifecycleOwner,
binding: ItemPageWebtoonBinding,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
View.OnClickListener {
) : BasePageHolder<ItemPageWebtoonBinding>(
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
lifecycleOwner = owner,
) {
override val ssiv = binding.ssiv
private var scrollToRestore = 0
private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar)
init {
binding.ssiv.bindToLifecycle(owner)
binding.ssiv.addOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this)
bindingInfo.buttonErrorDetails.setOnClickListener(this)
bindingInfo.progressBar.setVisibilityAfterHide(View.GONE)
}
override fun onResume() {
super.onResume()
binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
super.onConfigChanged()
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
binding.ssiv.applyDownSampling(isResumed())
}
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
}
override fun onRecycled() {
super.onRecycled()
binding.ssiv.recycle()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
goneOnInvisibleListener.attach()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
goneOnInvisibleListener.detach()
}
override fun onLoadingStarted() {
bindingInfo.layoutError.isVisible = false
bindingInfo.progressBar.show()
binding.ssiv.recycle()
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
} else {
bindingInfo.progressBar.isIndeterminate = true
}
}
override fun onPreviewReady(source: ImageSource) = Unit
override fun onImageReady(source: ImageSource) {
binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
override fun onReady() {
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
with(binding.ssiv) {
scrollTo(
@@ -109,31 +47,6 @@ class WebtoonHolder(
}
}
override fun onImageShown() {
bindingInfo.progressBar.hide()
}
override fun onTrimMemory() {
// TODO
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
fun getScrollY() = binding.ssiv.getScroll()
fun restoreScroll(scroll: Int) {

View File

@@ -67,7 +67,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
rv.addItemDecoration(WebtoonGapsDecoration())
}
}
viewModel.readerSettings.observe(viewLifecycleOwner) {
viewModel.readerSettingsProducer.observe(viewLifecycleOwner) {
it.applyBackground(binding.root)
}
}
@@ -81,7 +81,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
override fun onCreateAdapter() = WebtoonAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.withArgs
@@ -87,15 +88,15 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
}
private fun openInBrowser(url: String?) {
if (url.isNullOrEmpty()) {
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show()
} else {
if (url?.isHttpUrl() == true) {
router.openBrowser(
url = url,
source = viewModel.source,
title = viewModel.source.getTitle(requireContext()),
)
} else {
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
.show()
}
}

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
@@ -67,7 +68,7 @@ class SearchViewModel @Inject constructor(
val list: StateFlow<List<ListModel>> = combine(
results,
isLoading,
isLoading.dropWhile { !it },
includeDisabledSources,
) { list, loading, includeDisabled ->
when {

View File

@@ -34,7 +34,6 @@ import org.koitharu.kotatsu.settings.search.SettingsItem
import org.koitharu.kotatsu.settings.search.SettingsSearchFragment
import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment.Companion.EXTRA_SOURCE
import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment
import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
@@ -57,7 +56,7 @@ class SettingsActivity :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySettingsBinding.inflate(layoutInflater))
setDisplayHomeAsUp(true, false)
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
val fm = supportFragmentManager
val currentFragment = fm.findFragmentById(R.id.container)
if (currentFragment == null || (isMasterDetails && currentFragment is RootSettingsFragment)) {
@@ -151,7 +150,7 @@ class SettingsActivity :
AppRouter.ACTION_PROXY -> ProxySettingsFragment()
AppRouter.ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment()
AppRouter.ACTION_SOURCE -> SourceSettingsFragment.newInstance(
MangaSource(intent.getStringExtra(EXTRA_SOURCE)),
MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)),
)
AppRouter.ACTION_MANAGE_SOURCES -> SourcesManageFragment()

View File

@@ -37,7 +37,7 @@ import java.io.File
import java.io.FileNotFoundException
import java.util.EnumSet
import javax.inject.Inject
import com.google.android.material.R as materialR
import androidx.appcompat.R as appcompatR
@AndroidEntryPoint
class RestoreService : CoroutineIntentService() {
@@ -219,7 +219,7 @@ class RestoreService : CoroutineIntentService() {
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction(
materialR.drawable.material_ic_clear_black_24dp,
appcompatR.drawable.abc_ic_clear_material,
applicationContext.getString(android.R.string.cancel),
getCancelIntent(),
).build()

View File

@@ -9,6 +9,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
@@ -121,10 +122,8 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc
private const val KEY_AUTH = "auth"
private const val KEY_ENABLE = "enable"
const val EXTRA_SOURCE = "source"
fun newInstance(source: MangaSource) = SourceSettingsFragment().withArgs(1) {
putString(EXTRA_SOURCE, source.name)
putString(AppRouter.KEY_SOURCE, source.name)
}
}
}

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.HttpUrl
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -32,7 +33,7 @@ class SourceSettingsViewModel @Inject constructor(
private val mangaSourcesRepository: MangaSourcesRepository,
) : BaseViewModel(), SharedPreferences.OnSharedPreferenceChangeListener {
val source = MangaSource(savedStateHandle.get<String>(SourceSettingsFragment.EXTRA_SOURCE))
val source = MangaSource(savedStateHandle.get<String>(AppRouter.KEY_SOURCE))
val repository = mangaRepositoryFactory.create(source)
val onActionDone = MutableEventFlow<ReversibleAction>()

View File

@@ -14,46 +14,34 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BaseBrowserActivity
import org.koitharu.kotatsu.browser.BrowserCallback
import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment.Companion.EXTRA_SOURCE
import javax.inject.Inject
@AndroidEntryPoint
class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback {
@Inject
lateinit var mangaRepositoryFactory: MangaRepository.Factory
private lateinit var authProvider: MangaParserAuthProvider
override fun onCreate2(savedInstanceState: Bundle?) {
val source = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
if (source !is MangaParserSource) {
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
if (repository == null) {
finishAfterTransition()
return
}
val repository = mangaRepositoryFactory.create(source) as? ParserMangaRepository
authProvider = (repository)?.getAuthProvider() ?: run {
authProvider = repository.getAuthProvider() ?: run {
Toast.makeText(
this,
getString(R.string.auth_not_supported_by, source.title),
getString(R.string.auth_not_supported_by, source.getTitle(this)),
Toast.LENGTH_SHORT,
).show()
finishAfterTransition()
return
}
setDisplayHomeAsUp(true, true)
viewBinding.webView.configureForParser(repository.getRequestHeaders()[CommonHeaders.USER_AGENT])
viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this)
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
viewBinding.webView.webViewClient = BrowserClient(this)
lifecycleScope.launch {
try {
proxyProvider.applyWebViewConfig()
@@ -63,7 +51,7 @@ class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback {
if (savedInstanceState == null) {
val url = authProvider.authUrl
onTitleChanged(
source.title,
source.getTitle(this@SourceAuthActivity),
getString(R.string.loading_),
)
viewBinding.webView.loadUrl(url)
@@ -92,13 +80,10 @@ class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback {
}
class Contract : ActivityResultContract<MangaSource, Boolean>() {
override fun createIntent(context: Context, input: MangaSource): Intent {
return AppRouter.sourceAuthIntent(context, input)
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == RESULT_OK
}
override fun createIntent(context: Context, input: MangaSource) = AppRouter.sourceAuthIntent(context, input)
override fun parseResult(resultCode: Int, intent: Intent?) = resultCode == RESULT_OK
}
companion object {

View File

@@ -17,6 +17,7 @@ import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -117,7 +118,7 @@ class SourcesManageFragment :
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
(activity as? SettingsActivity)?.openFragment(
fragmentClass = SourceSettingsFragment::class.java,
args = Bundle(1).apply { putString(SourceSettingsFragment.EXTRA_SOURCE, item.source.name) },
args = Bundle(1).apply { putString(AppRouter.KEY_SOURCE, item.source.name) },
isFromRoot = false,
)
}

View File

@@ -86,7 +86,7 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.math.pow
import kotlin.random.Random
import com.google.android.material.R as materialR
import androidx.appcompat.R as appcompatR
@HiltWorker
class SuggestionsWorker @AssistedInject constructor(
@@ -137,7 +137,7 @@ class SuggestionsWorker @AssistedInject constructor(
false,
),
).addAction(
materialR.drawable.material_ic_clear_black_24dp,
appcompatR.drawable.abc_ic_clear_material,
applicationContext.getString(android.R.string.cancel),
workManager.createCancelPendingIntent(id),
)

View File

@@ -70,7 +70,7 @@ import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Provider
import kotlin.math.roundToInt
import com.google.android.material.R as materialR
import androidx.appcompat.R as appcompatR
@HiltWorker
class TrackWorker @AssistedInject constructor(
@@ -215,7 +215,7 @@ class TrackWorker @AssistedInject constructor(
),
)
addAction(
materialR.drawable.material_ic_clear_black_24dp,
appcompatR.drawable.abc_ic_clear_material,
applicationContext.getString(android.R.string.cancel),
workManager.createCancelPendingIntent(id),
)

View File

@@ -20,7 +20,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_margin="8dp"
android:layout_margin="@dimen/margin_small"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:textColorTertiary"

View File

@@ -5,15 +5,26 @@
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="android.widget.FrameLayout">
<com.google.android.material.progressindicator.CircularProgressIndicator
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:indeterminate="true"
android:visibility="gone"
app:hideAnimationBehavior="escape"
app:trackCornerRadius="0dp"
tools:visibility="visible" />
<TextView
android:id="@+id/textView_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:max="100"
app:hideAnimationBehavior="escape"
app:showAnimationBehavior="none" />
android:layout_marginHorizontal="60dp"
android:gravity="center"
android:textAppearance="?textAppearanceBodyLarge"
tools:text="72%" />
<LinearLayout
android:id="@+id/layout_error"
@@ -22,10 +33,12 @@
android:layout_gravity="center"
android:layout_marginStart="60dp"
android:layout_marginEnd="60dp"
android:background="@drawable/bg_card"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="@dimen/screen_padding"
android:visibility="gone"
tools:visibility="visible">
tools:visibility="gone">
<TextView
android:id="@+id/textView_error"

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="favourites">পছন্দের গুলো</string>
<string name="history">ইতিহা</string>
<string name="favourites">পছন্দের</string>
<string name="history">ম্প্রতি দেখা</string>
<string name="local_storage">লোকাল স্টোরেজ</string>
<string name="_continue">চালিয়ে যান</string>
<string name="clear_thumbs_cache">থাম্বনেইল ক্যাচ সাফ করুন</string>
@@ -12,20 +12,20 @@
<string name="app_update_available">অ্যাপের নতুন ভার্সন পাওয়া গেছে</string>
<string name="open_in_browser">ব্রাউজারে খুলুন</string>
<string name="error_occurred">কিছু একটা সমস্যা হয়েছে</string>
<string name="details">খুঁটিনাটি</string>
<string name="chapters">অধ্যায়</string>
<string name="details">বিস্তারিত</string>
<string name="chapters">্যাপ্টার</string>
<string name="list">তালিকা</string>
<string name="detailed_list">পুঙ্খানুপুঙ্খ তালিকা</string>
<string name="detailed_list">বিস্তারিত তালিকা</string>
<string name="grid">গ্রিড</string>
<string name="settings">সেটিং সমূহ</string>
<string name="settings">সেটিং</string>
<string name="loading_">লোড হচ্ছে…</string>
<string name="close">বন্ধ</string>
<string name="try_again">আবার চেষ্টা করুন</string>
<string name="try_again">আবার চেষ্টা করুন</string>
<string name="clear_history">ইতিহাস মুছুন</string>
<string name="computing_">প্রস্তুত হচ্ছে…</string>
<string name="chapter_d_of_d">%2$d টির মধ্যে %1$d তম পর্ব</string>
<string name="chapter_d_of_d">%2$d এর্ %1$d তম অধ্যায়</string>
<string name="nothing_found">কিছু পাওয়া যায়নি</string>
<string name="history_is_empty">কোনো ইতিহাস লেখা হয়নি</string>
<string name="history_is_empty">কোনো ইতিহাস নেই</string>
<string name="read">পড়ুন</string>
<string name="add_to_favourites">পছন্দ করুন</string>
<string name="text_file_not_supported">একটি ZIP অথবা CBZ ফাইল নিন</string>
@@ -38,7 +38,7 @@
<string name="operation_not_supported">এই কাজটি করা সম্ভব নয়</string>
<string name="switch_pages">পেজ পাল্টান</string>
<string name="search_history_cleared">সাফ করা হয়েছে</string>
<string name="network_error">নেটওয়ার্কে সমস্যা</string>
<string name="network_error">নেটওয়ার্কে ত্রুটি</string>
<string name="remote_sources">মানগা সোর্স সমূহ</string>
<string name="you_have_not_favourites_yet">এখনো কিছু পছন্দ হয়নি</string>
<string name="add_new_category">নতুন বিভাগ</string>
@@ -176,4 +176,7 @@
<string name="theme_name_dynamic">ডাইনামিক</string>
<string name="theme_name_miku">মিকু</string>
<string name="data_not_restored">ডেটা পুনরুদ্ধার করা হয়নি</string>
<string name="incognito_mode_hint">আপনার পড়ার অগ্রগতি সেভ হবে না</string>
<string name="volume_">আওয়াজ%d</string>
<string name="volume_unknown">অজানা ভলিউম</string>
</resources>

View File

@@ -19,7 +19,7 @@
<string name="history_is_empty">Zatím žádná historie</string>
<string name="read">Číst</string>
<string name="you_have_not_favourites_yet">Zatím žádné oblíbené</string>
<string name="add_to_favourites">Oblíbit toto</string>
<string name="add_to_favourites">Přidat do oblíbených</string>
<string name="add">Přidat</string>
<string name="share">Sdílet</string>
<string name="create_shortcut">Vytvořit zkratku…</string>
@@ -791,4 +791,15 @@
<string name="error_disclaimer_app_outdated">Vypadá to, že vaše verze Kotatsu je zastaralá. Prosíme, nainstalujte nejnovější verzi pro získání všech dostupných oprav chyb.</string>
<string name="disable_captcha_notifications">Vypnout oznámení o captcha</string>
<string name="disable_captcha_notifications_summary">Nebudete dostávat oznámení o řešení CAPTCHA pro tento zdroj, ale to může vést k rozbití operací na pozadí (hledání nových kapitol, získávání doporučení atd)</string>
<string name="tags_warnings">Zvýraznit nebezpečné žánry</string>
<string name="tags_warnings_summary">Zvýraznit žánry, které mohou být nevhodné pro většinu uživatelů</string>
<string name="nsfw_16">16+</string>
<string name="link_to_manga_in_app">Odkaz na mangau v Kotatsu</string>
<string name="clear_browser_data">Vyčistit data prohlížeče</string>
<string name="exclude_nsfw_from_suggestions_summary">Dospělá manga nebude zobrazena v návrzích. Tato funkce může být nepřesná s některými zdroji</string>
<string name="include_disabled_sources">Zahrnout vypnuté zdroje</string>
<string name="suggestions_disabled_sources_summary">Zobrazit návrhy ze všech zdrojů mangy, včetně vypnutých</string>
<string name="clear_browser_data_summary">Vyčistit data prohlížeče, např. cache a cookies. Upozornění: Všude budete odhlášeni a budte muset znovu řešit captcha</string>
<string name="global_search">Globální vyhledávání</string>
<string name="search_everywhere">Hledat všude</string>
</resources>

View File

@@ -671,7 +671,7 @@
<string name="seconds_short">s %d</string>
<string name="minutes_seconds_short">%1$d m %2$d s</string>
<string name="sfw">SFW</string>
<string name="not_in_favorites">Wala sa paborito mo</string>
<string name="not_in_favorites">Wala sa mga paborito</string>
<string name="unpopular">Hindi sikat</string>
<string name="low_rating">Mababa ang rating</string>
<string name="sort_order_asc">Pataas</string>
@@ -793,4 +793,16 @@
<string name="unnamed_chapter">Walang pangalan na kabanata</string>
<string name="error_disclaimer_app_outdated">Mukhang luma na ang bersyon mo ng Kotatsu. Mangyaring i-install ang pinakabagong bersyon upang makuha ang lahat ng magagamit na mga pag-aayos.</string>
<string name="error_disclaimer_report">Maaari kang magsumite ng ulat ng bug sa mga developer. Makakatulong ito sa amin na magsiyasat at ayusin ang isyu.</string>
<string name="tags_warnings">I-highlight ang mga mapanganib na genre</string>
<string name="nsfw_16">16+</string>
<string name="clear_browser_data">Linisin ang data ng browser</string>
<string name="no_write_permission_to_file">Walang pahintulot na magsulat ng file</string>
<string name="exclude_nsfw_from_suggestions_summary">Ang manga pang-matanda ay hindi ipapakita sa mga mungkahi. Maaaring gumana nang hindi tumpak ang opsyong ito sa ilang source</string>
<string name="include_disabled_sources">Isama ang mga hindi pinagana na source</string>
<string name="suggestions_disabled_sources_summary">Magpakita ng mga mungkahi mula sa lahat ng manga source, kabilang ang mga hindi napagana</string>
<string name="tags_warnings_summary">I-highlight ang mga genre na maaaring hindi naaangkop para sa karamihan ng mga user</string>
<string name="link_to_manga_in_app">Link sa manga sa Kotatsu</string>
<string name="simple">Pinasimple</string>
<string name="link_to_manga_on_s">Link sa manga sa %s</string>
<string name="clear_browser_data_summary">Linisin ang data ng browser tulad ng cache at mga cookie. Babala: Ang awtorisasyon sa mga source ng manga ay maaaring maging di-balido</string>
</resources>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="local_storage">Penyimpanan lokal</string>
<string name="favourites">Favorit</string>
<string name="favourites">Banyak disukai</string>
<string name="history">Riwayat</string>
<string name="error_occurred">Terjadi kesalahan</string>
<string name="network_error">Kesalahan jaringan</string>
@@ -18,7 +18,7 @@
<string name="nothing_found">Tidak ditemukan</string>
<string name="history_is_empty">Riwayat kosong</string>
<string name="read">Baca</string>
<string name="you_have_not_favourites_yet">Belum ada favorit</string>
<string name="you_have_not_favourites_yet">Belum ada yang disukai</string>
<string name="add_to_favourites">Buat favorit</string>
<string name="add_new_category">Kategori baru</string>
<string name="add">Tambah</string>
@@ -66,7 +66,7 @@
<string name="reader_settings">Pengaturan pembaca</string>
<string name="switch_pages">Ganti halaman</string>
<string name="chapters">Bab</string>
<string name="list">Daftari</string>
<string name="list">Daftar</string>
<string name="detailed_list">Daftar rinci</string>
<string name="webtoon">Webtoon</string>
<string name="read_mode">Mode baca</string>
@@ -204,7 +204,7 @@
<string name="create_category">Kategori baru</string>
<string name="light_indicator">Indikator LED</string>
<string name="vibration">Getaran</string>
<string name="favourites_categories">Kategori favorit</string>
<string name="favourites_categories">Kategori disukai</string>
<string name="remove_category">Hapus</string>
<string name="clear_updates_feed">Bersihkan aliran pembaruan</string>
<string name="right_to_left">Kanan-ke-kiri</string>

View File

@@ -27,7 +27,7 @@
<string name="search_manga">Cerca manga</string>
<string name="search">Cerca</string>
<string name="share_s">Condividi %s</string>
<string name="create_shortcut">Crea una scorciatoia</string>
<string name="create_shortcut">Crea scorciatoia</string>
<string name="share">Condividi</string>
<string name="save">Salva</string>
<string name="add">Aggiungi</string>
@@ -249,7 +249,7 @@
<string name="send">Invia</string>
<string name="disable_all">Disabilita tutto</string>
<string name="use_fingerprint">Usa le impronte digitali se disponibili</string>
<string name="appwidget_shelf_description">Manga dai preferiti</string>
<string name="appwidget_shelf_description">Manga dai tuoi preferiti</string>
<string name="appwidget_recent_description">I manga letti di recente</string>
<string name="report">Segnala</string>
<string name="tracking">Monitoraggio</string>
@@ -669,7 +669,7 @@
<string name="too_many_requests_message_retry">Troppe richieste. Riprova dopo %s</string>
<string name="skip_all">Salta tutto</string>
<string name="stuck">Bloccato</string>
<string name="not_in_favorites">Non nei preferiti</string>
<string name="not_in_favorites">Non presente nei preferiti</string>
<string name="plugin_incompatible">Plugin incompatibile o errore interno. Assicurati di utilizzare l\'ultima versione del plugin e di Kotatsu</string>
<string name="updated_long_ago">Aggiornato molto tempo fa</string>
<string name="unpopular">Impopolare</string>
@@ -804,4 +804,6 @@
<string name="include_disabled_sources">Includi fonti disabilitate</string>
<string name="suggestions_disabled_sources_summary">Mostra suggerimenti da tutte le fonti manga, incluse quelle disabilite</string>
<string name="exclude_nsfw_from_suggestions_summary">I manga per adulti non verranno mostrati nei suggerimenti. Questa opzione potrebbe non funzionare accuratamente con alcune fonti</string>
<string name="tags_warnings">Evidenzia i generi pericolosi</string>
<string name="tags_warnings_summary">Evidenzia i generi che potrebbero essere inappropriati per la maggior parte degli utenti</string>
</resources>

View File

@@ -95,7 +95,7 @@
<string name="cannot_find_available_storage">Sem espaço de armazenamento disponível</string>
<string name="other_storage">Outro armazenamento</string>
<string name="done">Feito</string>
<string name="all_favourites">Todas as favoritas</string>
<string name="all_favourites">Todos os favoritos</string>
<string name="favourites_category_empty">Categoria vazia</string>
<string name="read_later">Ler depois</string>
<string name="updates">Atualizações</string>
@@ -158,7 +158,7 @@
<string name="show_pages_numbers">Páginas numeradas</string>
<string name="screenshots_policy">Política de capturas de tela</string>
<string name="screenshots_allow">Permitir</string>
<string name="screenshots_block_nsfw">Bloquear no NSFW</string>
<string name="screenshots_block_nsfw">Bloquear conteúdo NSFW</string>
<string name="screenshots_block_all">Nunca permitir</string>
<string name="suggestions">Sugestões</string>
<string name="suggestions_enable">Ativar sugestões</string>
@@ -269,7 +269,7 @@
<string name="clear_cookies_summary">Pode ajudar em caso de problemas. Todas as autorizações serão invalidadas</string>
<string name="show_reading_indicators">Mostrar indicadores de progresso de leitura</string>
<string name="data_deletion">Exclusão de dados</string>
<string name="show_reading_indicators_summary">Mostrar porcentagem lida no histórico e em favoritas</string>
<string name="show_reading_indicators_summary">Mostrar porcentagem de leitura no histórico e nos favoritos</string>
<string name="exclude_nsfw_from_history_summary">Obras marcadas como NSFW nunca serão adicionadas ao histórico e seu progresso não será salvo</string>
<string name="show_all">Mostrar tudo</string>
<string name="clear_all_history">Limpar todo o histórico</string>
@@ -300,7 +300,7 @@
<string name="other_cache">Outro cache</string>
<string name="storage_usage">Uso do armazenamento</string>
<string name="available">Disponível</string>
<string name="removed_from_favourites">Removida das favoritas</string>
<string name="removed_from_favourites">Removido dos favoritos</string>
<string name="options">Opções</string>
<string name="incognito_mode">Modo anônimo</string>
<string name="automatic_scroll">Rolagem automática</string>
@@ -590,7 +590,7 @@
<string name="chapters_grid_view">Exibição em grade</string>
<string name="alternatives">Alternativas</string>
<string name="manga_migration">Migração de obra</string>
<string name="migration_completed">Migração completada</string>
<string name="migration_completed">Migração concluída</string>
<string name="chapters_deleted_pattern">%1$s removido, %2$s limpo</string>
<string name="delete_read_chapters">Apagar capítulos lidos</string>
<string name="delete_read_chapters_summary">Apagar capítulos lidos do armazenamento local para liberar espaço</string>
@@ -617,8 +617,8 @@
<string name="show_updated">Mostrar atualização</string>
<string name="disable_connectivity_check">Desativar a verificação de conectividade</string>
<string name="disable_connectivity_check_summary">Ignore a verificação de conectividade caso tenha problemas com ela (por exemplo, entrar no modo off-line mesmo que a rede esteja conectada)</string>
<string name="webtoon_gaps">Lacunas no modo webtoon</string>
<string name="webtoon_gaps_summary">Mostrar lacunas verticais entre as páginas no modo webtoon</string>
<string name="webtoon_gaps">Lacunas no modo Webtoon</string>
<string name="webtoon_gaps_summary">Mostrar lacunas verticais entre as páginas no modo Webtoon</string>
<string name="authors">Autores</string>
<string name="ignore_ssl_errors_summary">Você pode desativar a verificação de certificados SSL caso tenha problemas relacionados a SSL ao acessar recursos de rede. Isso pode afetar sua segurança. É necessário reiniciar o aplicativo após alterar essa configuração.</string>
<string name="search_suggestions">Sugestões de pesquisa</string>
@@ -633,7 +633,7 @@
<string name="new_chapters_pattern">%1$s: %2$d</string>
<string name="pin_navigation_ui">Fixar interface de navegação</string>
<string name="pin_navigation_ui_summary">Não esconder barra de navegação e visualização de pesquisa ao rolar</string>
<string name="_new">Novo</string>
<string name="_new">Novos</string>
<string name="all_languages">Todas os idiomas</string>
<string name="screenshots_block_incognito">Bloquear no modo de navegação anônima</string>
<string name="image_server">Servidor de imagem preferido</string>
@@ -711,7 +711,7 @@
<string name="minutes_seconds_short">%1$d min %2$d s</string>
<string name="unpopular">Impopular</string>
<string name="stuck">Preso</string>
<string name="not_in_favorites">Não está nas favoritas</string>
<string name="not_in_favorites">Não está nos favoritos</string>
<string name="fixing_manga">Corrigindo a obra</string>
<string name="fixed">Corrigida</string>
<string name="no_fix_required">Nenhuma correção necessária para \"%s\"</string>
@@ -768,7 +768,7 @@
<string name="rating">Avaliação</string>
<string name="source">Fonte</string>
<string name="restoring_backup">Restaurando backup</string>
<string name="error_disclaimer_manga">Tente abrir o manga em um navegador para ter certeza de que o mesmo está disponível na fonte</string>
<string name="error_disclaimer_manga">Tente abrir o manga em um navegador para ter certeza de que o mesmo está disponível na fonte.</string>
<string name="error_disclaimer_app_outdated">Parece que a sua versão do Kotatsu está desatualizada. Por favor instale a última versão para obter todos as correções disponíveis.</string>
<string name="search_everywhere">Pesquise em todos os lugares</string>
<string name="clear_browser_data">Limpar dados do navegador</string>
@@ -797,4 +797,10 @@
<string name="disable_captcha_notifications_summary">Você não receberá notificações sobre solucionar CAPTCHA para essa fonte, mas isso pode causar falha em operações de segundo plano (checagem de novos capítulos, obtenção de recomendações, etc)</string>
<string name="global_search">Pesquisa global</string>
<string name="badges_in_lists">Emblemas em listas</string>
<string name="tags_warnings">Destacar gêneros perigosos</string>
<string name="tags_warnings_summary">Destacar gêneros que podem ser inapropriados para a maioria dos usuários</string>
<string name="nsfw_16">+16</string>
<string name="exclude_nsfw_from_suggestions_summary">Mangás adultos não serão exibidos nas sugestões. Essa opção pode funcionar de forma imprecisa com algumas fontes</string>
<string name="include_disabled_sources">Incluir fontes desabilitadas</string>
<string name="suggestions_disabled_sources_summary">Mostrar sugestões de todas as fontes de mangá, incluindo as desabilitadas</string>
</resources>

View File

@@ -27,7 +27,7 @@
<string name="add">Добавить</string>
<string name="save">Сохранить</string>
<string name="share">Поделиться</string>
<string name="create_shortcut">Создать ярлык</string>
<string name="create_shortcut">Создать ярлык</string>
<string name="share_s">Поделиться %s</string>
<string name="search">Поиск</string>
<string name="search_manga">Поиск манги</string>
@@ -795,4 +795,15 @@
<string name="error_disclaimer_manga">Попробуйте открыть мангу в браузере, чтобы убедиться, что она доступна в источнике.</string>
<string name="disable_captcha_notifications">Отключить уведомления о CAPTCHA</string>
<string name="disable_captcha_notifications_summary">Вы не будете получать уведомления о прохождении CAPTCHA для этого источника, но это может привести к тому, что фоновые операции перестанут работать (проверка новых глав, обновление рекомендаций и т. д.)</string>
<string name="tags_warnings">Выделять опасные жанры</string>
<string name="tags_warnings_summary">Выделять жанры, которые могут быть неприемлемы для большинства пользователей</string>
<string name="clear_browser_data">Очистить данные браузера</string>
<string name="no_write_permission_to_file">Нет прав на запись в файл</string>
<string name="link_to_manga_on_s">Ссылка на мангу на %s</string>
<string name="exclude_nsfw_from_suggestions_summary">Взрослая манга не будет отображаться в рекомендациях. Эта опция может не работать с некоторыми источниками</string>
<string name="include_disabled_sources">Включить отключенные источники</string>
<string name="suggestions_disabled_sources_summary">Отображать рекомендации из всех источников манги, включая отключенные</string>
<string name="link_to_manga_in_app">Ссылка на мангу в Kotatsu</string>
<string name="nsfw_16">16+</string>
<string name="clear_browser_data_summary">Удалить данные встроенного браузера, такие как кэш и куки. Внимание: авторизация в источниках манги может быть потеряна</string>
</resources>

View File

@@ -16,7 +16,7 @@
<string name="nothing_found">Hiçbir şey bulunamadı</string>
<string name="history_is_empty">Geçmiş yok</string>
<string name="read">Oku</string>
<string name="you_have_not_favourites_yet">Henüz favorileriniz yok</string>
<string name="you_have_not_favourites_yet">Henüz favoriniz yok</string>
<string name="add_to_favourites">Favorilere ekle</string>
<string name="add_new_category">Yeni kategori</string>
<string name="add">Ekle</string>
@@ -804,4 +804,6 @@
<string name="exclude_nsfw_from_suggestions_summary">Yetişkin mangaları önerilerde gösterilmeyecektir. Bu seçenek her kaynak için doğru çalışmayabilir</string>
<string name="nsfw_16">16+</string>
<string name="include_disabled_sources">Devre dışı bırakılmış kaynakları dahil et</string>
<string name="tags_warnings">Riskli türleri işaretle</string>
<string name="tags_warnings_summary">Çoğu kullanıcılar için uygunsuz olabilecek türleri işaretle</string>
</resources>

View File

@@ -17,7 +17,7 @@
<string name="you_have_not_favourites_yet">Chưa có mục yêu thích nào</string>
<string name="add_to_favourites">Thêm vào mục yêu thích</string>
<string name="add">Thêm</string>
<string name="create_shortcut">Tạo lối tắt</string>
<string name="create_shortcut">Tạo lối tắt</string>
<string name="share_s">Chia sẻ %s</string>
<string name="search_manga">Tìm kiếm manga</string>
<string name="manga_downloading_">Đang tải xuống…</string>
@@ -450,7 +450,7 @@
<string name="suggest_new_sources_summary">Hiện cửa sổ bật các nguồn mới sau khi cập nhật ứng dụng</string>
<string name="by_relevance">Liên quan</string>
<string name="periodic_backups">Sao lưu định kì</string>
<string name="online_variant">Biến thể trên mạng</string>
<string name="online_variant">Xem chi tiết với nguồn</string>
<string name="frequency_once_per_week">Một lần mỗi tuần</string>
<string name="frequency_twice_per_month">Hai lần mỗi tháng</string>
<string name="categories">Thể loại</string>
@@ -802,4 +802,6 @@
<string name="include_disabled_sources">Bao gồm các nguồn đã bị vô hiệu hoá</string>
<string name="suggestions_disabled_sources_summary">Hiện gợi ý từ tất cả các nguồn đọc, bao gồm cả những nguồn đã bị vô hiệu hoá</string>
<string name="nsfw_16">16+</string>
<string name="tags_warnings">Highlight những thể loại không an toàn</string>
<string name="tags_warnings_summary">Highlight những thể loại mà nó không thực sự phù hợp với hầu hết người đọc (mọi lứa tuổi)</string>
</resources>

View File

@@ -59,7 +59,7 @@
<string name="add">添加</string>
<string name="save">保存</string>
<string name="share">分享</string>
<string name="create_shortcut">创建快捷方式</string>
<string name="create_shortcut">创建快捷方式</string>
<string name="share_s">分享 %s</string>
<string name="search">搜索</string>
<string name="search_manga">搜索漫画</string>
@@ -801,4 +801,6 @@
<string name="include_disabled_sources">包括已关闭图源</string>
<string name="suggestions_disabled_sources_summary">在搜索建议中显示所有图源,包括已关闭图源</string>
<string name="simple">默认</string>
<string name="tags_warnings">高亮敏感风格</string>
<string name="tags_warnings_summary">将可能会对大部分用户不友善的风格高亮</string>
</resources>

View File

@@ -31,7 +31,7 @@
<string name="add">Add</string>
<string name="save">Save</string>
<string name="share">Share</string>
<string name="create_shortcut">Create shortcut</string>
<string name="create_shortcut">Create shortcut</string>
<string name="share_s">Share %s</string>
<string name="search">Search</string>
<string name="search_manga">Search manga</string>

View File

@@ -31,7 +31,7 @@ material = "1.13.0-alpha12"
moshi = "1.15.2"
okhttp = "4.12.0"
okio = "3.10.2"
parsers = "8bb0c4f4f1"
parsers = "20a24db949"
preference = "1.2.1"
recyclerview = "1.4.0"
room = "2.6.1"