Merge branch 'devel' into feature/colorfilter

pull/216/head
Koitharu 4 years ago
commit 6a0a4023ad
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -7,16 +7,16 @@ plugins {
} }
android { android {
compileSdkVersion 32 compileSdkVersion 33
buildToolsVersion '32.0.0' buildToolsVersion '33.0.0'
namespace 'org.koitharu.kotatsu' namespace 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 33
versionCode 490 versionCode 493
versionName '4.0-a1' versionName '4.0-a4'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -29,6 +29,8 @@ android {
// define this values in your local.properties file // define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\"" buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\"" buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
resValue "string", "acra_login", "${localProperty('acra.login')}"
resValue "string", "acra_password", "${localProperty('acra.password')}"
} }
buildTypes { buildTypes {
debug { debug {
@ -80,7 +82,7 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:8709c3dd0c') { implementation('com.github.KotatsuApp:kotatsu-parsers:f112a06ab6') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@ -88,7 +90,7 @@ dependencies {
implementation 'androidx.core:core-ktx:1.8.0' implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.5.1' implementation 'androidx.activity:activity-ktx:1.5.1'
implementation 'androidx.fragment:fragment-ktx:1.5.1' implementation 'androidx.fragment:fragment-ktx:1.5.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-service:2.5.1' implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
@ -100,7 +102,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-alpha03' implementation 'com.google.android.material:material:1.7.0-beta01'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
@ -115,18 +117,19 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation "com.google.dagger:hilt-android:2.42" implementation "com.google.dagger:hilt-android:2.43.2"
kapt "com.google.dagger:hilt-compiler:2.42" kapt "com.google.dagger:hilt-compiler:2.43.2"
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.1.0' implementation 'io.coil-kt:coil-base:2.2.0'
implementation 'io.coil-kt:coil-svg:2.1.0' implementation 'io.coil-kt:coil-svg:2.2.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' // implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:2942b797a2'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-mail:5.9.5' implementation 'ch.acra:acra-http:5.9.6'
implementation 'ch.acra:acra-dialog:5.9.5' implementation 'ch.acra:acra-dialog:5.9.6'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
@ -144,6 +147,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.4.3' androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.42' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.43.2'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.42' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.43.2'
} }

@ -10,4 +10,7 @@
} }
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment -keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; } -keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-dontwarn okhttp3.internal.platform.ConscryptPlatform -dontwarn okhttp3.internal.platform.ConscryptPlatform
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment

@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.READ_SYNC_STATS" /> <uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"

@ -15,9 +15,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
import org.acra.config.mailSender import org.acra.config.httpSender
import org.acra.data.StringFormat import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
@ -59,16 +60,24 @@ class KotatsuApp : Application(), Configuration.Provider {
super.attachBaseContext(base) super.attachBaseContext(base)
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.KEY_VALUE_LIST reportFormat = StringFormat.JSON
excludeMatchingSharedPreferencesKeys = listOf(
"sources_\\w+",
)
httpSender {
uri = getString(R.string.url_error_report)
basicAuthLogin = getString(R.string.acra_login)
basicAuthPassword = getString(R.string.acra_password)
httpMethod = HttpSender.Method.POST
}
reportContent = listOf( reportContent = listOf(
ReportField.PACKAGE_NAME, ReportField.PACKAGE_NAME,
ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME, ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION, ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL, ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE, ReportField.STACK_TRACE,
ReportField.CUSTOM_DATA, ReportField.CRASH_CONFIGURATION,
ReportField.SHARED_PREFERENCES, ReportField.SHARED_PREFERENCES,
) )
dialog { dialog {
@ -78,11 +87,6 @@ class KotatsuApp : Application(), Configuration.Provider {
resIcon = R.drawable.ic_alert_outline resIcon = R.drawable.ic_alert_outline
resTheme = android.R.style.Theme_Material_Light_Dialog_Alert resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
} }
mailSender {
mailTo = getString(R.string.email_error_report)
reportAsFile = true
reportFileName = "stacktrace.txt"
}
} }
} }

@ -5,6 +5,8 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
class MangaIntent private constructor( class MangaIntent private constructor(
val manga: Manga?, val manga: Manga?,
@ -13,15 +15,15 @@ class MangaIntent private constructor(
) { ) {
constructor(intent: Intent?) : this( constructor(intent: Intent?) : this(
manga = intent?.getParcelableExtra<ParcelableManga>(KEY_MANGA)?.manga, manga = intent?.getParcelableExtraCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
uri = intent?.data uri = intent?.data,
) )
constructor(args: Bundle?) : this( constructor(args: Bundle?) : this(
manga = args?.getParcelable<ParcelableManga>(KEY_MANGA)?.manga, manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
uri = null uri = null,
) )
companion object { companion object {

@ -21,14 +21,14 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
viewBinding = binding viewBinding = binding
return MaterialAlertDialogBuilder(requireContext(), theme) return MaterialAlertDialogBuilder(requireContext(), theme)
.setView(binding.root) .setView(binding.root)
.also(::onBuildDialog) .run(::onBuildDialog)
.create() .create()
} }
final override fun onCreateView( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?,
) = viewBinding?.root ) = viewBinding?.root
@CallSuper @CallSuper
@ -37,9 +37,9 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
super.onDestroyView() super.onDestroyView()
} }
open fun onBuildDialog(builder: MaterialAlertDialogBuilder) = Unit open fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder = builder
protected fun bindingOrNull(): B? = viewBinding protected fun bindingOrNull(): B? = viewBinding
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
} }

@ -8,16 +8,18 @@ import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import kotlin.math.hypot
import org.koitharu.kotatsu.utils.ext.animatorDurationScale import org.koitharu.kotatsu.utils.ext.animatorDurationScale
import org.koitharu.kotatsu.utils.ext.measureWidth import org.koitharu.kotatsu.utils.ext.measureWidth
import kotlin.math.hypot
class BubbleAnimator( class BubbleAnimator(
private val bubble: View, private val bubble: View,
) { ) {
private val animationDuration = (bubble.resources.getInteger(android.R.integer.config_shortAnimTime) * private val animationDuration = (
bubble.context.animatorDurationScale).toLong() bubble.resources.getInteger(android.R.integer.config_shortAnimTime) *
bubble.context.animatorDurationScale
).toLong()
private var animator: Animator? = null private var animator: Animator? = null
private var isHiding = false private var isHiding = false
@ -65,12 +67,12 @@ class BubbleAnimator(
private var isCancelled = false private var isCancelled = false
override fun onAnimationCancel(animation: Animator?) { override fun onAnimationCancel(animation: Animator) {
super.onAnimationCancel(animation) super.onAnimationCancel(animation)
isCancelled = true isCancelled = true
} }
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
if (!isCancelled && animation === this@BubbleAnimator.animator) { if (!isCancelled && animation === this@BubbleAnimator.animator) {
bubble.isInvisible = true bubble.isInvisible = true
@ -79,4 +81,4 @@ class BubbleAnimator(
} }
} }
} }
} }

@ -14,8 +14,10 @@ class ScrollbarAnimator(
private val scrollbarPaddingEnd: Float, private val scrollbarPaddingEnd: Float,
) { ) {
private val animationDuration = (scrollbar.resources.getInteger(R.integer.config_defaultAnimTime) * private val animationDuration = (
scrollbar.context.animatorDurationScale).toLong() scrollbar.resources.getInteger(R.integer.config_defaultAnimTime) *
scrollbar.context.animatorDurationScale
).toLong()
private var animator: ViewPropertyAnimator? = null private var animator: ViewPropertyAnimator? = null
private var isHiding = false private var isHiding = false
@ -40,30 +42,32 @@ class ScrollbarAnimator(
} }
animator?.cancel() animator?.cancel()
isHiding = true isHiding = true
animator = scrollbar animator = scrollbar.animate().apply {
.animate() translationX(scrollbarPaddingEnd)
.translationX(scrollbarPaddingEnd) alpha(0f)
.alpha(0f) duration = animationDuration
.setDuration(animationDuration) setListener(HideListener(this))
.setListener(HideListener()) }
} }
private inner class HideListener : AnimatorListenerAdapter() { private inner class HideListener(
private val viewPropertyAnimator: ViewPropertyAnimator,
) : AnimatorListenerAdapter() {
private var isCancelled = false private var isCancelled = false
override fun onAnimationCancel(animation: Animator?) { override fun onAnimationCancel(animation: Animator) {
super.onAnimationCancel(animation) super.onAnimationCancel(animation)
isCancelled = true isCancelled = true
} }
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
if (!isCancelled && animation === this@ScrollbarAnimator.animator) { if (!isCancelled && this@ScrollbarAnimator.animator === viewPropertyAnimator) {
scrollbar.isInvisible = true scrollbar.isInvisible = true
isHiding = false isHiding = false
this@ScrollbarAnimator.animator = null this@ScrollbarAnimator.animator = null
} }
} }
} }
} }

@ -2,15 +2,11 @@ package org.koitharu.kotatsu.base.ui.widgets
import android.animation.LayoutTransition import android.animation.LayoutTransition
import android.content.Context import android.content.Context
import android.transition.AutoTransition
import android.transition.TransitionManager
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
@ -30,6 +26,8 @@ import org.koitharu.kotatsu.utils.ext.getAnimationDuration
import org.koitharu.kotatsu.utils.ext.getThemeDrawable import org.koitharu.kotatsu.utils.ext.getThemeDrawable
import org.koitharu.kotatsu.utils.ext.parents import org.koitharu.kotatsu.utils.ext.parents
private const val THROTTLE_DELAY = 200L
class BottomSheetHeaderBar @JvmOverloads constructor( class BottomSheetHeaderBar @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@ -39,11 +37,17 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
private val binding = LayoutSheetHeaderBinding.inflate(LayoutInflater.from(context), this) private val binding = LayoutSheetHeaderBinding.inflate(LayoutInflater.from(context), this)
private val closeDrawable = context.getThemeDrawable(materialR.attr.actionModeCloseDrawable) private val closeDrawable = context.getThemeDrawable(materialR.attr.actionModeCloseDrawable)
private val bottomSheetCallback = Callback() private val bottomSheetCallback = Callback()
private val adjustStateRunnable = Runnable { adjustState() }
private var bottomSheetBehavior: BottomSheetBehavior<*>? = null private var bottomSheetBehavior: BottomSheetBehavior<*>? = null
private val locationBuffer = IntArray(2) private val locationBuffer = IntArray(2)
private val expansionListeners = LinkedList<OnExpansionChangeListener>() private val expansionListeners = LinkedList<OnExpansionChangeListener>()
private var fitStatusBar = false private var fitStatusBar = false
private var transition: AutoTransition? = null private val minHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_min)
private val maxHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_max)
private var isLayoutSuppressedCompat = false
private var isLayoutCalledWhileSuppressed = false
private var isBsExpanded = false
private var stateAdjustedAt = 0L
@Deprecated("") @Deprecated("")
val toolbar: MaterialToolbar val toolbar: MaterialToolbar
@ -156,6 +160,14 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
binding.toolbar.setSubtitle(resId) binding.toolbar.setSubtitle(resId)
} }
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (isLayoutSuppressedCompat) {
isLayoutCalledWhileSuppressed = true
} else {
super.onLayout(changed, l, t, r, b)
}
}
private fun setBottomSheetBehavior(behavior: BottomSheetBehavior<*>?) { private fun setBottomSheetBehavior(behavior: BottomSheetBehavior<*>?) {
bottomSheetBehavior?.removeBottomSheetCallback(bottomSheetCallback) bottomSheetBehavior?.removeBottomSheetCallback(bottomSheetCallback)
bottomSheetBehavior = behavior bottomSheetBehavior = behavior
@ -166,15 +178,20 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
} }
private fun onBottomSheetStateChanged(newState: Int) { private fun onBottomSheetStateChanged(newState: Int) {
val isExpanded = newState == BottomSheetBehavior.STATE_EXPANDED && isOnTopOfScreen() val expanded = newState == BottomSheetBehavior.STATE_EXPANDED && isOnTopOfScreen()
if (isExpanded == binding.dragHandle.isGone) { if (isBsExpanded != expanded) {
return isBsExpanded = expanded
postAdjustState()
} }
TransitionManager.beginDelayedTransition(this, getTransition()) }
binding.toolbar.navigationIcon = (if (isExpanded) closeDrawable else null)
binding.dragHandle.isGone = isExpanded private fun suppressLayoutCompat(suppress: Boolean) {
expansionListeners.forEach { it.onExpansionStateChanged(this, isExpanded) } if (suppress == isLayoutSuppressedCompat) return
dispatchInsets(ViewCompat.getRootWindowInsets(this)) isLayoutSuppressedCompat = suppress
if (!suppress && isLayoutCalledWhileSuppressed) {
requestLayout()
}
isLayoutCalledWhileSuppressed = false
} }
private fun dispatchInsets(insets: WindowInsetsCompat?) { private fun dispatchInsets(insets: WindowInsetsCompat?) {
@ -182,11 +199,14 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
return return
} }
val isExpanded = binding.dragHandle.isGone val isExpanded = binding.dragHandle.isGone
val topInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
if (isExpanded) { if (isExpanded) {
val topInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
updatePadding(top = topInset) updatePadding(top = topInset)
} else { } else {
updatePadding(top = 0) updatePadding(top = 0)
binding.dragHandle.updateLayoutParams {
height = topInset.coerceIn(minHandleHeight, maxHandleHeight)
}
} }
} }
@ -225,7 +245,7 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
return true return true
} }
val viewId = child.id val viewId = child.id
return viewId == R.id.dragHandle || viewId == R.id.toolbar || viewId == R.id.frame return viewId == R.id.dragHandle || viewId == R.id.toolbar
} }
private fun convertLayoutParams(params: ViewGroup.LayoutParams?): Toolbar.LayoutParams? { private fun convertLayoutParams(params: ViewGroup.LayoutParams?): Toolbar.LayoutParams? {
@ -242,13 +262,24 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
} }
} }
private fun getTransition(): AutoTransition { private fun postAdjustState() {
transition?.let { return it } removeCallbacks(adjustStateRunnable)
val t = AutoTransition() val now = System.currentTimeMillis()
t.duration = context.getAnimationDuration(android.R.integer.config_shortAnimTime) if (stateAdjustedAt + THROTTLE_DELAY < now) {
t.addTarget(binding.dragHandle) adjustState()
transition = t } else {
return t postDelayed(adjustStateRunnable, THROTTLE_DELAY)
}
}
private fun adjustState() {
suppressLayoutCompat(true)
binding.toolbar.navigationIcon = (if (isBsExpanded) closeDrawable else null)
binding.dragHandle.isGone = isBsExpanded
expansionListeners.forEach { it.onExpansionStateChanged(this, isBsExpanded) }
dispatchInsets(ViewCompat.getRootWindowInsets(this))
stateAdjustedAt = System.currentTimeMillis()
suppressLayoutCompat(false)
} }
private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), View.OnClickListener { private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), View.OnClickListener {

@ -100,7 +100,7 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
.applySystemAnimatorScale(context) .applySystemAnimatorScale(context)
.setListener( .setListener(
object : AnimatorListenerAdapter() { object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
currentAnimator = null currentAnimator = null
postInvalidate() postInvalidate()
} }

@ -42,7 +42,7 @@ class BookmarksViewModel @Inject constructor(
BookmarksGroup(manga, bookmarks) BookmarksGroup(manga, bookmarks)
} }
} }
.catch { e -> e.toErrorState(canRetry = false) } .catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun removeBookmarks(ids: Map<Manga, Set<Long>>) { fun removeBookmarks(ids: Map<Manga, Set<Long>>) {

@ -55,11 +55,12 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onDestroyView() { override fun onDestroyView() {
binding.webView.stopLoading() binding.webView.stopLoading()
binding.webView.destroy()
super.onDestroyView() super.onDestroyView()
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setNegativeButton(android.R.string.cancel, null) return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
} }
override fun onResume() { override fun onResume() {
@ -83,7 +84,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onCheckPassed() { override fun onCheckPassed() {
pendingResult.putBoolean(EXTRA_RESULT, true) pendingResult.putBoolean(EXTRA_RESULT, true)
dismiss() dismissAllowingStateLoss()
} }
companion object { companion object {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.db.dao package org.koitharu.kotatsu.core.db.dao
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@ -8,8 +9,8 @@ import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
interface TrackLogsDao { interface TrackLogsDao {
@Transaction @Transaction
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
suspend fun findAll(offset: Int, limit: Int): List<TrackLogWithManga> fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
@Query("DELETE FROM track_logs") @Query("DELETE FROM track_logs")
suspend fun clear() suspend fun clear()
@ -25,4 +26,4 @@ interface TrackLogsDao {
@Query("SELECT COUNT(*) FROM track_logs") @Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int suspend fun count(): Int
} }

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.db.entity package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
@ -10,7 +11,7 @@ import org.koitharu.kotatsu.utils.ext.longHashCode
fun TagEntity.toMangaTag() = MangaTag( fun TagEntity.toMangaTag() = MangaTag(
key = this.key, key = this.key,
title = this.title.toTitleCase(), title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source), source = MangaSource(this.source) ?: MangaSource.DUMMY,
) )
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag) fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
@ -19,7 +20,7 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id, id = this.id,
title = this.title, title = this.title,
altTitle = this.altTitle, altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) }, state = this.state?.let { MangaState(it) },
rating = this.rating, rating = this.rating,
isNsfw = this.isNsfw, isNsfw = this.isNsfw,
url = this.url, url = this.url,
@ -27,8 +28,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
coverUrl = this.coverUrl, coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl, largeCoverUrl = this.largeCoverUrl,
author = this.author, author = this.author,
source = MangaSource.valueOf(this.source), source = MangaSource(this.source) ?: MangaSource.DUMMY,
tags = tags tags = tags,
) )
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags()) fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
@ -54,14 +55,18 @@ fun MangaTag.toEntity() = TagEntity(
title = title, title = title,
key = key, key = key,
source = source.name, source = source.name,
id = "${key}_${source.name}".longHashCode() id = "${key}_${source.name}".longHashCode(),
) )
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity) fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
// Other // Other
@Suppress("FunctionName")
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching { fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name) SortOrder.valueOf(name)
}.getOrDefault(fallback) }.getOrDefault(fallback)
@Suppress("FunctionName")
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.getOrNull()

@ -11,8 +11,10 @@ import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@ -53,11 +55,11 @@ class AppUpdateRepository @Inject constructor(
} }
} }
suspend fun fetchUpdate(): AppVersion? { suspend fun fetchUpdate(): AppVersion? = withContext(Dispatchers.Default) {
if (!isUpdateSupported()) { if (!isUpdateSupported()) {
return null return@withContext null
} }
return runCatching { runCatching {
val currentVersion = VersionId(BuildConfig.VERSION_NAME) val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList() val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId } available.sortBy { it.versionId }

@ -2,10 +2,12 @@ package org.koitharu.kotatsu.core.os
import android.app.ActivityManager import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import android.media.ThumbnailUtils import android.media.ThumbnailUtils
import android.os.Build import android.os.Build
import android.util.Size import android.util.Size
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
@ -22,6 +24,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
@ -35,13 +38,18 @@ class ShortcutsUpdater @Inject constructor(
private val coil: ImageLoader, private val coil: ImageLoader,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val mangaRepository: MangaDataRepository, private val mangaRepository: MangaDataRepository,
) : InvalidationTracker.Observer(TABLE_HISTORY) { private val settings: AppSettings,
) : InvalidationTracker.Observer(TABLE_HISTORY), SharedPreferences.OnSharedPreferenceChangeListener {
private val iconSize by lazy { getIconSize(context) } private val iconSize by lazy { getIconSize(context) }
private var shortcutsUpdateJob: Job? = null private var shortcutsUpdateJob: Job? = null
override fun onInvalidated(tables: MutableSet<String>) { init {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { settings.subscribe(this)
}
override fun onInvalidated(tables: Set<String>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1 || !settings.isDynamicShortcutsEnabled) {
return return
} }
val prevJob = shortcutsUpdateJob val prevJob = shortcutsUpdateJob
@ -51,6 +59,16 @@ class ShortcutsUpdater @Inject constructor(
} }
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && key == AppSettings.KEY_SHORTCUTS) {
if (settings.isDynamicShortcutsEnabled) {
onInvalidated(emptySet())
} else {
clearShortcuts()
}
}
}
suspend fun requestPinShortcut(manga: Manga): Boolean { suspend fun requestPinShortcut(manga: Manga): Boolean {
return ShortcutManagerCompat.requestPinShortcut( return ShortcutManagerCompat.requestPinShortcut(
context, context,
@ -64,6 +82,15 @@ class ShortcutsUpdater @Inject constructor(
return shortcutsUpdateJob?.join() != null return shortcutsUpdateJob?.join() != null
} }
fun isDynamicShortcutsAvailable(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return false
}
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
return manager.maxShortcutCountPerActivity > 0
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private suspend fun updateShortcutsImpl() = runCatching { private suspend fun updateShortcutsImpl() = runCatching {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity) val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
@ -74,6 +101,15 @@ class ShortcutsUpdater @Inject constructor(
it.printStackTraceDebug() it.printStackTraceDebug()
} }
@RequiresApi(Build.VERSION_CODES.N_MR1)
private fun clearShortcuts() {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
try {
manager.removeAllDynamicShortcuts()
} catch (_: IllegalStateException) {
}
}
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder { private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder {
val icon = runCatching { val icon = runCatching {
val bmp = coil.execute( val bmp = coil.execute(

@ -11,6 +11,12 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
@ -19,12 +25,6 @@ import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Singleton
class AppSettings @Inject constructor(@ApplicationContext context: Context) { class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@ -65,6 +65,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val readerPageSwitch: Set<String> val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS) get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
var isTrafficWarningEnabled: Boolean var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
@ -73,13 +76,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true) get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) } set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
val isUpdateCheckingEnabled: Boolean
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
var lastUpdateCheckTimestamp: Long
get() = prefs.getLong(KEY_APP_UPDATE, 0L)
set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) }
val isTrackerEnabled: Boolean val isTrackerEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true) get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
@ -141,6 +137,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isExitConfirmationEnabled: Boolean val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false) get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
val isDynamicShortcutsEnabled: Boolean
get() = prefs.getBoolean(KEY_SHORTCUTS, true)
var sourcesOrder: List<String> var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null) get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|') ?.split('|')
@ -324,10 +323,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_INCOGNITO_MODE = "incognito" const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync" const val KEY_SYNC = "sync"
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation" const val KEY_APP_TRANSLATION = "about_app_translation"
private const val NETWORK_NEVER = 0 private const val NETWORK_NEVER = 0

@ -0,0 +1,72 @@
package org.koitharu.kotatsu.core.ui
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.parseAsHtml
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.databinding.DialogMangaErrorBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.requireParcelable
import org.koitharu.kotatsu.utils.ext.requireSerializable
import org.koitharu.kotatsu.utils.ext.withArgs
class MangaErrorDialog : AlertDialogFragment<DialogMangaErrorBinding>() {
private lateinit var error: Throwable
private lateinit var manga: Manga
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = requireArguments()
manga = args.requireParcelable<ParcelableManga>(ARG_MANGA).manga
error = args.requireSerializable(ARG_ERROR)
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogMangaErrorBinding {
return DialogMangaErrorBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding.textViewMessage) {
movementMethod = LinkMovementMethod.getInstance()
text = context.getString(
R.string.manga_error_description_pattern,
this@MangaErrorDialog.error.message?.htmlEncode().orEmpty(),
manga.publicUrl,
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.report) { _, _ ->
dismiss()
error.report(TAG)
}.setTitle(R.string.error_occurred)
}
companion object {
private const val TAG = "MangaErrorDialog"
private const val ARG_ERROR = "error"
private const val ARG_MANGA = "manga"
fun show(fm: FragmentManager, manga: Manga, error: Throwable) = MangaErrorDialog().withArgs(2) {
putParcelable(ARG_MANGA, ParcelableManga(manga, false))
putSerializable(ARG_ERROR, error)
}.show(fm, TAG)
}
}

@ -35,13 +35,13 @@ class ChaptersMenuProvider(
else -> false else -> false
} }
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
bottomSheetMediator?.lock() bottomSheetMediator?.lock()
return true return true
} }
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
(item?.actionView as? SearchView)?.setQuery("", false) (item.actionView as? SearchView)?.setQuery("", false)
viewModel.performChapterSearch(null) viewModel.performChapterSearch(null)
bottomSheetMediator?.unlock() bottomSheetMediator?.unlock()
return true return true

@ -5,8 +5,13 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Bundle import android.os.Bundle
import android.transition.Slide
import android.transition.TransitionManager
import android.view.Gravity
import android.view.Menu import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
@ -16,6 +21,7 @@ import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@ -27,6 +33,7 @@ import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.ui.MangaErrorDialog
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
@ -93,6 +100,7 @@ class DetailsActivity :
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) { viewModel.onShowToast.observe(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
} }
viewModel.historyInfo.observe(this, ::onHistoryChanged) viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranchName.observe(this) { viewModel.selectedBranchName.observe(this) {
@ -158,8 +166,11 @@ class DetailsActivity :
private fun onMangaUpdated(manga: Manga) { private fun onMangaUpdated(manga: Manga) {
title = manga.title title = manga.title
binding.buttonRead.isEnabled = !manga.chapters.isNullOrEmpty() val hasChapters = !manga.chapters.isNullOrEmpty()
binding.buttonRead.isEnabled = hasChapters
invalidateOptionsMenu() invalidateOptionsMenu()
showBottomSheet(manga.chapters != null)
binding.groupHeader?.isVisible = hasChapters
} }
private fun onMangaRemoved(manga: Manga) { private fun onMangaRemoved(manga: Manga) {
@ -172,17 +183,17 @@ class DetailsActivity :
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
val manga = viewModel.manga.value
when { when {
ExceptionResolver.canResolve(e) -> { ExceptionResolver.canResolve(e) -> {
resolveError(e) resolveError(e)
} }
viewModel.manga.value == null -> { manga == null -> {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition() finishAfterTransition()
} }
else -> { else -> {
val snackbar = Snackbar.make( val snackbar = makeSnackbar(
binding.containerDetails,
e.getDisplayMessage(resources), e.getDisplayMessage(resources),
if (viewModel.manga.value?.chapters == null) { if (viewModel.manga.value?.chapters == null) {
Snackbar.LENGTH_INDEFINITE Snackbar.LENGTH_INDEFINITE
@ -190,10 +201,9 @@ class DetailsActivity :
Snackbar.LENGTH_LONG Snackbar.LENGTH_LONG
}, },
) )
snackbar.anchorView = binding.headerChapters
if (e.isReportable()) { if (e.isReportable()) {
snackbar.setAction(R.string.report) { snackbar.setAction(R.string.details) {
e.report("DetailsActivity::onError") MangaErrorDialog.show(supportFragmentManager, manga, e)
} }
} }
snackbar.show() snackbar.show()
@ -238,8 +248,7 @@ class DetailsActivity :
fun showChapterMissingDialog(chapterId: Long) { fun showChapterMissingDialog(chapterId: Long) {
val remoteManga = viewModel.getRemoteManga() val remoteManga = viewModel.getRemoteManga()
if (remoteManga == null) { if (remoteManga == null) {
val snackbar = Snackbar.make(binding.containerDetails, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT) val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
snackbar.anchorView = binding.headerChapters
snackbar.show() snackbar.show()
return return
} }
@ -291,6 +300,24 @@ class DetailsActivity :
private fun isTabletLayout() = binding.layoutBottom == null private fun isTabletLayout() = binding.layoutBottom == null
private fun showBottomSheet(isVisible: Boolean) {
val view = binding.layoutBottom ?: return
if (view.isVisible == isVisible) return
val transition = Slide(Gravity.BOTTOM)
transition.addTarget(view)
transition.interpolator = AccelerateDecelerateInterpolator()
TransitionManager.beginDelayedTransition(binding.root as ViewGroup, transition)
view.isVisible = isVisible
}
private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar {
val sb = Snackbar.make(binding.containerDetails, text, duration)
if (binding.layoutBottom?.isVisible == true) {
sb.anchorView = binding.headerChapters
}
return sb
}
companion object { companion object {
fun newIntent(context: Context, manga: Manga): Intent { fun newIntent(context: Context, manga: Manga): Intent {

@ -70,6 +70,7 @@ class DetailsFragment :
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this) binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
@ -228,6 +229,14 @@ class DetailsFragment :
), ),
) )
} }
R.id.textView_source -> {
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
),
)
}
R.id.imageView_cover -> { R.id.imageView_cover -> {
startActivity( startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }), ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),

@ -28,4 +28,4 @@ fun MangaChapter.toListItem(
uploadDateMs = uploadDate, uploadDateMs = uploadDate,
dateFormat = dateFormat, dateFormat = dateFormat,
) )
} }

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.assistedViewModels import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getSerializableCompat
@AndroidEntryPoint @AndroidEntryPoint
class FavouritesCategoryEditActivity : class FavouritesCategoryEditActivity :
@ -70,8 +71,8 @@ class FavouritesCategoryEditActivity :
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState) super.onRestoreInstanceState(savedInstanceState)
val order = savedInstanceState.getSerializable(KEY_SORT_ORDER) val order = savedInstanceState.getSerializableCompat<SortOrder>(KEY_SORT_ORDER)
if (order != null && order is SortOrder) { if (order != null) {
selectedSortOrder = order selectedSortOrder = order
} }
} }

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class FavouritesListViewModel @AssistedInject constructor( class FavouritesListViewModel @AssistedInject constructor(
@ -53,7 +54,7 @@ class FavouritesListViewModel @AssistedInject constructor(
} else { } else {
repository.observeAll(categoryId) repository.observeAll(categoryId)
}, },
createListModeFlow() createListModeFlow(),
) { list, mode -> ) { list, mode ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
@ -66,13 +67,13 @@ class FavouritesListViewModel @AssistedInject constructor(
R.string.favourites_category_empty R.string.favourites_category_empty
}, },
actionStringRes = 0, actionStringRes = 0,
) ),
) )
else -> list.toUi(mode, this) else -> list.toUi(mode, this)
} }
}.catch { }.catch {
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init { init {
if (categoryId != NO_ID) { if (categoryId != NO_ID) {

@ -23,7 +23,7 @@ import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
@ -60,8 +60,8 @@ class HistoryListViewModel @Inject constructor(
}.onFirst { }.onFirst {
loadingCounter.decrement() loadingCounter.decrement()
}.catch { }.catch {
it.toErrorState(canRetry = false) emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override fun onRefresh() = Unit override fun onRefresh() = Unit

@ -56,17 +56,17 @@ class ReadingProgressView @JvmOverloads constructor(
getProgressDrawable().progress = p getProgressDrawable().progress = p
} }
override fun onAnimationStart(animation: Animator?) = Unit override fun onAnimationStart(animation: Animator) = Unit
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
if (percentAnimator === animation) { if (percentAnimator === animation) {
percentAnimator = null percentAnimator = null
} }
} }
override fun onAnimationCancel(animation: Animator?) = Unit override fun onAnimationCancel(animation: Animator) = Unit
override fun onAnimationRepeat(animation: Animator?) = Unit override fun onAnimationRepeat(animation: Animator) = Unit
fun setPercent(value: Float, animate: Boolean) { fun setPercent(value: Float, animate: Boolean) {
val currentDrawable = peekProgressDrawable() val currentDrawable = peekProgressDrawable()

@ -27,7 +27,7 @@ import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
private const val HISTORY_MAX_SEGMENTS = 2 private const val HISTORY_MAX_SEGMENTS = 2
@ -49,8 +49,8 @@ class LibraryViewModel @Inject constructor(
) { history, favourites -> ) { history, favourites ->
mapList(history, favourites) mapList(history, favourites)
}.catch { e -> }.catch { e ->
e.toErrorState(canRetry = false) emit(listOf(e.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override suspend fun getCounter(mangaId: Long): Int { override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId) return trackingRepository.getNewChaptersCount(mangaId)

@ -6,6 +6,7 @@ import android.view.View
import android.widget.TextView import android.widget.TextView
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -46,12 +47,12 @@ class ItemSizeResolver(resources: Resources, private val settings: AppSettings)
} }
} }
override fun onViewAttachedToWindow(v: View?) { override fun onViewAttachedToWindow(v: View) {
settings.subscribe(this) settings.subscribe(this)
update() update()
} }
override fun onViewDetachedFromWindow(v: View?) { override fun onViewDetachedFromWindow(v: View) {
settings.unsubscribe(this) settings.unsubscribe(this)
} }
@ -77,7 +78,7 @@ class ItemSizeResolver(resources: Resources, private val settings: AppSettings)
} }
if (textAppearanceResId != prevTextAppearance) { if (textAppearanceResId != prevTextAppearance) {
prevTextAppearance = textAppearanceResId prevTextAppearance = textAppearanceResId
setTextAppearance(textAppearanceResId) TextViewCompat.setTextAppearance(this, textAppearanceResId)
requestLayout() requestLayout()
} }
} }

@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup
@ -17,7 +18,6 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.DialogListModeBinding import org.koitharu.kotatsu.databinding.DialogListModeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ListModeSelectDialog : class ListModeSelectDialog :
@ -33,8 +33,9 @@ class ListModeSelectDialog :
container: ViewGroup?, container: ViewGroup?,
) = DialogListModeBinding.inflate(inflater, container, false) ) = DialogListModeBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setTitle(R.string.list_mode) return super.onBuildDialog(builder)
.setTitle(R.string.list_mode)
.setPositiveButton(R.string.done, null) .setPositiveButton(R.string.done, null)
.setCancelable(true) .setCancelable(true)
} }

@ -1,9 +1,11 @@
package org.koitharu.kotatsu.list.ui.filter package org.koitharu.kotatsu.list.ui.filter
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
class FilterAdapter( class FilterAdapter(
listener: OnFilterChangedListener, listener: OnFilterChangedListener,
listListener: ListListener<FilterItem>,
) : AsyncListDifferDelegationAdapter<FilterItem>( ) : AsyncListDifferDelegationAdapter<FilterItem>(
FilterDiffCallback(), FilterDiffCallback(),
filterSortDelegate(listener), filterSortDelegate(listener),
@ -11,4 +13,9 @@ class FilterAdapter(
filterHeaderDelegate(), filterHeaderDelegate(),
filterLoadingDelegate(), filterLoadingDelegate(),
filterErrorDelegate(), filterErrorDelegate(),
) ) {
init {
differ.addListListener(listListener)
}
}

@ -6,18 +6,21 @@ import android.os.Bundle
import android.view.* import android.view.*
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import dagger.hilt.android.AndroidEntryPoint import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.LinearLayoutManager
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
import org.koitharu.kotatsu.utils.ext.parentFragmentViewModels import org.koitharu.kotatsu.utils.ext.parentFragmentViewModels
class FilterBottomSheet : class FilterBottomSheet :
BaseBottomSheet<SheetFilterBinding>(), BaseBottomSheet<SheetFilterBinding>(),
MenuItem.OnActionExpandListener, MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener, SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener { DialogInterface.OnKeyListener,
AsyncListDiffer.ListListener<FilterItem> {
private val viewModel by parentFragmentViewModels<RemoteListViewModel>() private val viewModel by parentFragmentViewModels<RemoteListViewModel>()
@ -33,13 +36,13 @@ class FilterBottomSheet :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val adapter = FilterAdapter(viewModel) val adapter = FilterAdapter(viewModel, this)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems) viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems)
initOptionsMenu() initOptionsMenu()
} }
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true) setExpanded(isExpanded = true, isLocked = true)
return true return true
} }
@ -71,6 +74,12 @@ class FilterBottomSheet :
return false return false
} }
override fun onCurrentListChanged(previousList: MutableList<FilterItem>, currentList: MutableList<FilterItem>) {
if (currentList.size > previousList.size && view != null) {
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
}
}
private fun initOptionsMenu() { private fun initOptionsMenu() {
binding.headerBar.toolbar.inflateMenu(R.menu.opt_filter) binding.headerBar.toolbar.inflateMenu(R.menu.opt_filter)
val searchMenuItem = binding.headerBar.toolbar.menu.findItem(R.id.action_search) val searchMenuItem = binding.headerBar.toolbar.menu.findItem(R.id.action_search)

@ -63,6 +63,10 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
x.tags.containsAll(tags) x.tags.containsAll(tags)
} }
} }
when (sortOrder) {
SortOrder.ALPHABETICAL -> list.sortBy { it.title }
SortOrder.RATING -> list.sortBy { it.rating }
}
return list return list
} }
@ -250,7 +254,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
} }
} }
override val sortOrders = setOf(SortOrder.ALPHABETICAL) override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING)
override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getPageUrl(page: MangaPage) = page.url

@ -27,8 +27,9 @@ class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.On
return DialogImportBinding.inflate(inflater, container, false) return DialogImportBinding.inflate(inflater, container, false)
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setTitle(R.string._import) return super.onBuildDialog(builder)
.setTitle(R.string._import)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setCancelable(true) .setCancelable(true)
} }

@ -1,5 +1,8 @@
package org.koitharu.kotatsu.main.ui package org.koitharu.kotatsu.main.ui
import android.Manifest
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.SparseIntArray import android.util.SparseIntArray
import android.view.MenuItem import android.view.MenuItem
@ -7,6 +10,7 @@ import android.view.View
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
@ -24,7 +28,6 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
@ -291,12 +294,11 @@ class MainActivity :
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext) SuggestionsWorker.setup(applicationContext)
} }
requestNotificationsPermission()
when { when {
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager) !settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager) settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
} }
yield()
// TODO get<SyncController>().requestFullSyncAndGc(get())
} }
} }
@ -347,6 +349,15 @@ class MainActivity :
showNav(!isOpened) showNav(!isOpened)
} }
private fun requestNotificationsPermission() {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
}
}
private inner class VoiceInputCallback : ActivityResultCallback<String?> { private inner class VoiceInputCallback : ActivityResultCallback<String?> {
override fun onActivityResult(result: String?) { override fun onActivityResult(result: String?) {

@ -4,26 +4,28 @@ import android.util.SparseIntArray
import androidx.core.util.set import androidx.core.util.set
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val settings: AppSettings,
private val appUpdateRepository: AppUpdateRepository, private val appUpdateRepository: AppUpdateRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
syncController: SyncController,
database: MangaDatabase,
) : BaseViewModel() { ) : BaseViewModel() {
val onOpenReader = SingleLiveEvent<Manga>() val onOpenReader = SingleLiveEvent<Manga>()
@ -43,9 +45,12 @@ class MainViewModel @Inject constructor(
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0)) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, SparseIntArray(0))
init { init {
launchJob(Dispatchers.Default) { launchJob {
appUpdateRepository.fetchUpdate() appUpdateRepository.fetchUpdate()
} }
launchJob {
syncController.requestFullSyncAndGc(database)
}
} }
fun openLastReader() { fun openLastReader() {

@ -6,6 +6,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@Singleton @Singleton
@ -14,7 +15,7 @@ class AppProtectHelper @Inject constructor(private val settings: AppSettings) :
private var isUnlocked = settings.appPassword.isNullOrEmpty() private var isUnlocked = settings.appPassword.isNullOrEmpty()
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity !is ProtectActivity && !isUnlocked) { if (!isUnlocked && activity !is ProtectActivity && activity !is CrashReportDialog) {
val sourceIntent = Intent(activity, activity.javaClass) val sourceIntent = Intent(activity, activity.javaClass)
activity.intent?.let { activity.intent?.let {
sourceIntent.putExtras(it) sourceIntent.putExtras(it)

@ -22,6 +22,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityProtectBinding import org.koitharu.kotatsu.databinding.ActivityProtectBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
@AndroidEntryPoint @AndroidEntryPoint
class ProtectActivity : class ProtectActivity :
@ -44,7 +45,7 @@ class ProtectActivity :
viewModel.onError.observe(this, this::onError) viewModel.onError.observe(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged) viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onUnlockSuccess.observe(this) { viewModel.onUnlockSuccess.observe(this) {
val intent = intent.getParcelableExtra<Intent>(EXTRA_INTENT) val intent = intent.getParcelableExtraCompat<Intent>(EXTRA_INTENT)
startActivity(intent) startActivity(intent)
finishAfterTransition() finishAfterTransition()
} }

@ -6,6 +6,8 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.roundToInt
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@ -17,9 +19,8 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> { class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
@ -33,7 +34,7 @@ class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemC
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val chapters = arguments?.getParcelable<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters val chapters = arguments?.getParcelableCompat<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters
if (chapters.isNullOrEmpty()) { if (chapters.isNullOrEmpty()) {
dismissAllowingStateLoss() dismissAllowingStateLoss()
return return

@ -40,6 +40,7 @@ import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.GridTouchHelper import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.IdlingDetector
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
@ -51,15 +52,18 @@ class ReaderActivity :
OnPageSelectListener, OnPageSelectListener,
ReaderConfigBottomSheet.Callback, ReaderConfigBottomSheet.Callback,
ReaderControlDelegate.OnInteractionListener, ReaderControlDelegate.OnInteractionListener,
OnApplyWindowInsetsListener { OnApplyWindowInsetsListener,
IdlingDetector.Callback {
@Inject @Inject
lateinit var viewModelFactory: ReaderViewModel.Factory lateinit var viewModelFactory: ReaderViewModel.Factory
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
val viewModel by assistedViewModels { val viewModel by assistedViewModels {
viewModelFactory.create( viewModelFactory.create(
intent = MangaIntent(intent), intent = MangaIntent(intent),
initialState = intent?.getParcelableExtra(EXTRA_STATE), initialState = intent?.getParcelableExtraCompat(EXTRA_STATE),
preselectedBranch = intent?.getStringExtra(EXTRA_BRANCH), preselectedBranch = intent?.getStringExtra(EXTRA_BRANCH),
) )
} }
@ -70,6 +74,9 @@ class ReaderActivity :
pageSwitchTimer.delaySec = value pageSwitchTimer.delaySec = value
} }
override val readerMode: ReaderMode?
get() = readerManager.currentMode
private lateinit var pageSwitchTimer: PageSwitchTimer private lateinit var pageSwitchTimer: PageSwitchTimer
private lateinit var touchHelper: GridTouchHelper private lateinit var touchHelper: GridTouchHelper
private lateinit var controlDelegate: ReaderControlDelegate private lateinit var controlDelegate: ReaderControlDelegate
@ -84,11 +91,12 @@ class ReaderActivity :
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this) touchHelper = GridTouchHelper(this, this)
pageSwitchTimer = PageSwitchTimer(this, this) pageSwitchTimer = PageSwitchTimer(this, this)
controlDelegate = ReaderControlDelegate(lifecycleScope, settings, this) controlDelegate = ReaderControlDelegate(settings, this, this)
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected) binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
binding.slider.setLabelFormatter(PageLabelFormatter()) binding.slider.setLabelFormatter(PageLabelFormatter())
ReaderSliderListener(this, viewModel).attachToSlider(binding.slider) ReaderSliderListener(this, viewModel).attachToSlider(binding.slider)
insetsDelegate.interceptingWindowInsetsListener = this insetsDelegate.interceptingWindowInsetsListener = this
idlingDetector.bindToLifecycle(this)
viewModel.onError.observe(this, this::onError) viewModel.onError.observe(this, this::onError)
viewModel.readerMode.observe(this, this::onInitReader) viewModel.readerMode.observe(this, this::onInitReader)
@ -111,6 +119,11 @@ class ReaderActivity :
override fun onUserInteraction() { override fun onUserInteraction() {
super.onUserInteraction() super.onUserInteraction()
pageSwitchTimer.onUserInteraction() pageSwitchTimer.onUserInteraction()
idlingDetector.onUserInteraction()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
} }
private fun onInitReader(mode: ReaderMode) { private fun onInitReader(mode: ReaderMode) {

@ -15,14 +15,15 @@ import org.koitharu.kotatsu.databinding.DialogReaderConfigBinding
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@Deprecated("Not in use") @Deprecated("Not in use")
class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(), class ReaderConfigDialog :
AlertDialogFragment<DialogReaderConfigBinding>(),
CheckableButtonGroup.OnCheckedChangeListener { CheckableButtonGroup.OnCheckedChangeListener {
private lateinit var mode: ReaderMode private lateinit var mode: ReaderMode
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = DialogReaderConfigBinding.inflate(inflater, container, false) ) = DialogReaderConfigBinding.inflate(inflater, container, false)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -32,8 +33,9 @@ class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(),
?: ReaderMode.STANDARD ?: ReaderMode.STANDARD
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setTitle(R.string.read_mode) return super.onBuildDialog(builder)
.setTitle(R.string.read_mode)
.setPositiveButton(R.string.done, null) .setPositiveButton(R.string.done, null)
.setCancelable(true) .setCancelable(true)
} }
@ -48,8 +50,10 @@ class ReaderConfigDialog : AlertDialogFragment<DialogReaderConfigBinding>(),
} }
override fun onDismiss(dialog: DialogInterface) { override fun onDismiss(dialog: DialogInterface) {
((parentFragment as? Callback) (
?: (activity as? Callback))?.onReaderModeChanged(mode) (parentFragment as? Callback)
?: (activity as? Callback)
)?.onReaderModeChanged(mode)
super.onDismiss(dialog) super.onDismiss(dialog)
} }

@ -1,33 +1,39 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.content.SharedPreferences
import android.view.KeyEvent import android.view.KeyEvent
import android.view.SoundEffectConstants import android.view.SoundEffectConstants
import android.view.View import android.view.View
import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.DefaultLifecycleObserver
import kotlinx.coroutines.Dispatchers import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.utils.GridTouchHelper import org.koitharu.kotatsu.utils.GridTouchHelper
class ReaderControlDelegate( class ReaderControlDelegate(
scope: LifecycleCoroutineScope, private val settings: AppSettings,
settings: AppSettings,
private val listener: OnInteractionListener, private val listener: OnInteractionListener,
) { owner: LifecycleOwner,
) : DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener {
private var isTapSwitchEnabled: Boolean = true private var isTapSwitchEnabled: Boolean = true
private var isVolumeKeysSwitchEnabled: Boolean = false private var isVolumeKeysSwitchEnabled: Boolean = false
private var isReaderTapsAdaptive: Boolean = true
init { init {
settings.observeAsFlow(AppSettings.KEY_READER_SWITCHERS) { readerPageSwitch } owner.lifecycle.addObserver(this)
.flowOn(Dispatchers.Default) settings.subscribe(this)
.onEach { updateSettings()
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in it }
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in it
}.launchIn(scope) override fun onDestroy(owner: LifecycleOwner) {
settings.unsubscribe(this)
owner.lifecycle.removeObserver(this)
super.onDestroy(owner)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
updateSettings()
} }
fun onGridTouch(area: Int, view: View) { fun onGridTouch(area: Int, view: View) {
@ -41,7 +47,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP) view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP)
} }
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) { GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
listener.switchPageBy(-1) listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT) view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT)
} }
GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) { GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) {
@ -49,7 +55,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN) view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN)
} }
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) { GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
listener.switchPageBy(1) listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT) view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT)
} }
} }
@ -72,19 +78,25 @@ class ReaderControlDelegate(
KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_RIGHT,
-> { -> {
listener.switchPageBy(1) listener.switchPageBy(1)
true true
} }
KeyEvent.KEYCODE_DPAD_RIGHT -> {
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
true
}
KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_LEFT,
-> { -> {
listener.switchPageBy(-1) listener.switchPageBy(-1)
true true
} }
KeyEvent.KEYCODE_DPAD_LEFT -> {
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
true
}
KeyEvent.KEYCODE_DPAD_CENTER -> { KeyEvent.KEYCODE_DPAD_CENTER -> {
listener.toggleUiVisibility() listener.toggleUiVisibility()
true true
@ -99,8 +111,21 @@ class ReaderControlDelegate(
) )
} }
private fun updateSettings() {
val switch = settings.readerPageSwitch
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in switch
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in switch
isReaderTapsAdaptive = settings.isReaderTapsAdaptive
}
private fun isReaderTapsReversed(): Boolean {
return isReaderTapsAdaptive && listener.readerMode == ReaderMode.REVERSED
}
interface OnInteractionListener { interface OnInteractionListener {
val readerMode: ReaderMode?
fun switchPageBy(delta: Int) fun switchPageBy(delta: Int)
fun toggleUiVisibility() fun toggleUiVisibility()

@ -8,17 +8,19 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Rect import android.graphics.Rect
import android.icu.text.SimpleDateFormat
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.measureDimension
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
class ReaderInfoBarView @JvmOverloads constructor( class ReaderInfoBarView @JvmOverloads constructor(
@ -29,23 +31,46 @@ class ReaderInfoBarView @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textBounds = Rect() private val textBounds = Rect()
private val inset = context.resources.resolveDp(2f)
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT) private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
private val timeReceiver = TimeReceiver() private val timeReceiver = TimeReceiver()
private var insetLeft: Int = 0
private var insetRight: Int = 0
private var insetTop: Int = 0
private val colorText = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
200,
)
private val colorOutline = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorSurface, Color.WHITE),
200,
)
private var timeText = timeFormat.format(Date()) private var timeText = timeFormat.format(Date())
private var text: String = "" private var text: String = ""
private val innerHeight private val innerHeight
get() = height - inset - inset - paddingTop - paddingBottom get() = height - paddingTop - paddingBottom - insetTop
private val innerWidth private val innerWidth
get() = width - inset - inset - paddingLeft - paddingRight get() = width - paddingLeft - paddingRight - insetLeft - insetRight
init { init {
paint.color = ColorUtils.setAlphaComponent( paint.strokeWidth = context.resources.resolveDp(2f)
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK), val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
160, val insetStart = getSystemUiDimensionOffset("status_bar_padding_start") + insetCorner
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end") + insetCorner
val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL
insetLeft = if (isRtl) insetEnd else insetStart
insetRight = if (isRtl) insetStart else insetEnd
insetTop = minOf(insetLeft, insetRight)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = suggestedMinimumWidth + paddingLeft + paddingRight + insetLeft + insetRight
val desiredHeight = suggestedMinimumHeight + paddingTop + paddingBottom + insetTop
setMeasuredDimension(
measureDimension(desiredWidth, widthMeasureSpec),
measureDimension(desiredHeight, heightMeasureSpec),
) )
} }
@ -53,9 +78,9 @@ class ReaderInfoBarView @JvmOverloads constructor(
super.onDraw(canvas) super.onDraw(canvas)
val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom
paint.textAlign = Paint.Align.LEFT paint.textAlign = Paint.Align.LEFT
canvas.drawText(text, paddingLeft + inset, paddingTop + inset + ty, paint) canvas.drawTextOutline(text, (paddingLeft + insetLeft).toFloat(), paddingTop + insetTop + ty)
paint.textAlign = Paint.Align.RIGHT paint.textAlign = Paint.Align.RIGHT
canvas.drawText(timeText, width - paddingRight - inset, paddingTop + inset + ty, paint) canvas.drawTextOutline(timeText, (width - paddingRight - insetRight).toFloat(), paddingTop + insetTop + ty)
} }
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@ -103,6 +128,15 @@ class ReaderInfoBarView @JvmOverloads constructor(
paint.getTextBounds(str, 0, str.length, textBounds) paint.getTextBounds(str, 0, str.length, textBounds)
} }
private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) {
paint.color = colorOutline
paint.style = Paint.Style.STROKE
drawText(text, x, y, paint)
paint.color = colorText
paint.style = Paint.Style.FILL
drawText(text, x, y, paint)
}
private inner class TimeReceiver : BroadcastReceiver() { private inner class TimeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
@ -110,4 +144,13 @@ class ReaderInfoBarView @JvmOverloads constructor(
invalidate() invalidate()
} }
} }
private fun getSystemUiDimensionOffset(name: String): Int = runCatching {
val manager = context.packageManager
val resources = manager.getResourcesForApplication("com.android.systemui")
val resId = resources.getIdentifier(name, "dimen", "com.android.systemui")
resources.getDimensionPixelOffset(resId)
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(0)
} }

@ -8,6 +8,7 @@ import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
private const val KEY_STATE = "state" private const val KEY_STATE = "state"
@ -18,7 +19,7 @@ abstract class BaseReader<B : ViewBinding> : BaseFragment<B>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
var restoredState = savedInstanceState?.getParcelable<ReaderState?>(KEY_STATE) var restoredState = savedInstanceState?.getParcelableCompat<ReaderState>(KEY_STATE)
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
onPagesChanged(it.pages, restoredState ?: it.state) onPagesChanged(it.pages, restoredState ?: it.state)

@ -4,12 +4,12 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.resetTransformations import org.koitharu.kotatsu.utils.ext.resetTransformations
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>( abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
@ -45,7 +45,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
final override fun onCreateViewHolder( final override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: Int viewType: Int,
): H = onCreateViewHolder(parent, loader, settings, exceptionResolver) ): H = onCreateViewHolder(parent, loader, settings, exceptionResolver)
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont -> suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont ->
@ -58,7 +58,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: AppSettings, settings: AppSettings,
exceptionResolver: ExceptionResolver exceptionResolver: ExceptionResolver,
): H ): H
private class DiffCallback : DiffUtil.ItemCallback<ReaderPage>() { private class DiffCallback : DiffUtil.ItemCallback<ReaderPage>() {
@ -70,6 +70,5 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean { override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }
} }

@ -69,7 +69,7 @@ open class PageHolder(
override fun onImageShowing(zoom: ZoomMode) { override fun onImageShowing(zoom: ZoomMode) {
binding.ssiv.maxScale = 2f * maxOf( binding.ssiv.maxScale = 2f * maxOf(
binding.ssiv.width / binding.ssiv.sWidth.toFloat(), binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
binding.ssiv.height / binding.ssiv.sHeight.toFloat() binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
) )
when (zoom) { when (zoom) {
ZoomMode.FIT_CENTER -> { ZoomMode.FIT_CENTER -> {
@ -81,7 +81,7 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat() binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat()
binding.ssiv.setScaleAndCenter( binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale, binding.ssiv.minScale,
PointF(0f, binding.ssiv.sHeight / 2f) PointF(0f, binding.ssiv.sHeight / 2f),
) )
} }
ZoomMode.FIT_WIDTH -> { ZoomMode.FIT_WIDTH -> {
@ -89,14 +89,14 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat() binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat()
binding.ssiv.setScaleAndCenter( binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale, binding.ssiv.minScale,
PointF(binding.ssiv.sWidth / 2f, 0f) PointF(binding.ssiv.sWidth / 2f, 0f),
) )
} }
ZoomMode.KEEP_START -> { ZoomMode.KEEP_START -> {
binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE) binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE)
binding.ssiv.setScaleAndCenter( binding.ssiv.setScaleAndCenter(
binding.ssiv.maxScale, binding.ssiv.maxScale,
PointF(0f, 0f) PointF(0f, 0f),
) )
} }
} }
@ -115,9 +115,9 @@ open class PageHolder(
override fun onError(e: Throwable) { override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources) bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText( bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again } ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
) )
bindingInfo.layoutError.isVisible = true bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hideCompat() bindingInfo.progressBar.hideCompat()
} }
} }

@ -24,6 +24,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@ -52,7 +53,7 @@ class PagesThumbnailsSheet :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val pages = arguments?.getParcelable<ParcelableMangaPages>(ARG_PAGES)?.pages val pages = arguments?.getParcelableCompat<ParcelableMangaPages>(ARG_PAGES)?.pages
if (pages.isNullOrEmpty()) { if (pages.isNullOrEmpty()) {
dismissAllowingStateLoss() dismissAllowingStateLoss()
return return

@ -99,7 +99,7 @@ class RemoteListFragment : MangaListFragment() {
override fun onQueryTextChange(newText: String?): Boolean = false override fun onQueryTextChange(newText: String?): Boolean = false
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true) (activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
return true return true
} }

@ -10,10 +10,13 @@ private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor { class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder() val sourceRequest = chain.request()
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI) request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
storage.accessToken?.let { if (!sourceRequest.url.pathSegments.contains("oauth")) {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
} }
val response = chain.proceed(request.build()) val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) { if (!response.isSuccessful && !response.isRedirect) {
@ -21,4 +24,4 @@ class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor
} }
return response return response
} }
} }

@ -40,13 +40,14 @@ class ShikimoriRepository(
suspend fun authorize(code: String?) { suspend fun authorize(code: String?) {
val body = FormBody.Builder() val body = FormBody.Builder()
body.add("grant_type", "authorization_code")
body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID) body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID)
body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET) body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET)
if (code != null) { if (code != null) {
body.add("grant_type", "authorization_code")
body.add("redirect_uri", REDIRECT_URI) body.add("redirect_uri", REDIRECT_URI)
body.add("code", code) body.add("code", code)
} else { } else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken)) body.add("refresh_token", checkNotNull(storage.refreshToken))
} }
val request = Request.Builder() val request = Request.Builder()

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikiMangaSelectionDe
import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter
import org.koitharu.kotatsu.utils.ext.assistedViewModels import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.requireParcelable
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@AndroidEntryPoint @AndroidEntryPoint
@ -47,7 +48,7 @@ class ScrobblingSelectorBottomSheet :
private val viewModel by assistedViewModels { private val viewModel by assistedViewModels {
viewModelFactory.create( viewModelFactory.create(
requireNotNull(requireArguments().getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga, requireArguments().requireParcelable<ParcelableManga>(MangaIntent.KEY_MANGA).manga,
) )
} }
@ -84,7 +85,7 @@ class ScrobblingSelectorBottomSheet :
dismiss() dismiss()
} }
viewModel.searchQuery.observe(viewLifecycleOwner) { viewModel.searchQuery.observe(viewLifecycleOwner) {
binding.headerBar.toolbar.subtitle = it binding.headerBar.subtitle = it
} }
} }
@ -102,7 +103,7 @@ class ScrobblingSelectorBottomSheet :
viewModel.loadList(append = true) viewModel.loadList(append = true)
} }
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true) setExpanded(isExpanded = true, isLocked = true)
return true return true
} }

@ -17,6 +17,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
@AndroidEntryPoint @AndroidEntryPoint
class MangaListActivity : class MangaListActivity :
@ -29,7 +30,7 @@ class MangaListActivity :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityContainerBinding.inflate(layoutInflater)) setContentView(ActivityContainerBinding.inflate(layoutInflater))
val tags = intent.getParcelableExtra<ParcelableMangaTags>(EXTRA_TAGS)?.tags val tags = intent.getParcelableExtraCompat<ParcelableMangaTags>(EXTRA_TAGS)?.tags
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
val source = intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: tags?.firstOrNull()?.source val source = intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: tags?.firstOrNull()?.source
if (source == null) { if (source == null) {

@ -1,110 +0,0 @@
package org.koitharu.kotatsu.settings
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.activity.ComponentActivity
import androidx.annotation.MainThread
import androidx.core.net.toUri
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.github.VersionId
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
@Deprecated("")
class AppUpdateChecker(private val activity: ComponentActivity) {
private val settings: AppSettings = TODO()
private val repo: AppUpdateRepository = TODO()
suspend fun checkIfNeeded(): Boolean? = if (
settings.isUpdateCheckingEnabled &&
settings.lastUpdateCheckTimestamp + PERIOD < System.currentTimeMillis()
) {
checkNow()
} else {
null
}
suspend fun checkNow() = runCatching {
val version = repo.fetchUpdate() ?: return@runCatching false
val newVersionId = VersionId(version.name)
val currentVersionId = VersionId(BuildConfig.VERSION_NAME)
val result = newVersionId > currentVersionId
if (result) {
withContext(Dispatchers.Main) {
showUpdateDialog(version)
}
}
settings.lastUpdateCheckTimestamp = System.currentTimeMillis()
result
}.onFailure {
it.printStackTraceDebug()
}.getOrNull()
@MainThread
private fun showUpdateDialog(version: AppVersion) {
val message = buildString {
append(activity.getString(R.string.new_version_s, version.name))
appendLine()
append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize)))
appendLine()
appendLine()
append(version.description)
}
MaterialAlertDialogBuilder(activity, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.app_update_available)
.setMessage(message)
.setIcon(R.drawable.ic_app_update)
.setPositiveButton(R.string.download) { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri())
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser)))
}
.setNegativeButton(R.string.close, null)
.setCancelable(false)
.create()
.show()
}
companion object {
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
private val PERIOD = TimeUnit.HOURS.toMillis(6)
fun isUpdateSupported(context: Context): Boolean {
return BuildConfig.DEBUG || getCertificateSHA1Fingerprint(context) == CERT_SHA1
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(context: Context): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
}
}

@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
@ -41,8 +42,13 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
@Inject @Inject
lateinit var cookieJar: AndroidCookieJar lateinit var cookieJar: AndroidCookieJar
@Inject
lateinit var shortcutsUpdater: ShortcutsUpdater
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_history) addPreferencesFromResource(R.xml.pref_history)
findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
shortcutsUpdater.isDynamicShortcutsAvailable()
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

@ -2,6 +2,8 @@ package org.koitharu.kotatsu.settings
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -53,7 +55,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(KEY_AUTH)?.run { findPreference<Preference>(KEY_AUTH)?.run {
if (isVisible) { if (isVisible) {
loadUsername(this) loadUsername(viewLifecycleOwner, this)
} }
} }
} }
@ -68,7 +70,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
} }
} }
private fun loadUsername(preference: Preference) = viewLifecycleScope.launch { private fun loadUsername(owner: LifecycleOwner, preference: Preference) = owner.lifecycleScope.launch {
runCatching { runCatching {
preference.summary = null preference.summary = null
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@ -99,7 +101,8 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
viewLifecycleScope.launch { viewLifecycleScope.launch {
if (exceptionResolver.resolve(error)) { if (exceptionResolver.resolve(error)) {
val pref = findPreference<Preference>(KEY_AUTH) ?: return@launch val pref = findPreference<Preference>(KEY_AUTH) ?: return@launch
loadUsername(pref) val lifecycleOwner = awaitViewLifecycle()
loadUsername(lifecycleOwner, pref)
} }
} }
} }

@ -2,34 +2,41 @@ package org.koitharu.kotatsu.settings.about
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import androidx.preference.Preference import androidx.preference.Preference
import kotlinx.coroutines.launch import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
private val viewModel by viewModels<AboutSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_about) addPreferencesFromResource(R.xml.pref_about)
val isUpdateSupported = AppUpdateChecker.isUpdateSupported(requireContext())
findPreference<Preference>(AppSettings.KEY_APP_UPDATE_AUTO)?.run {
isVisible = isUpdateSupported
}
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run { findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
title = getString(R.string.app_version, BuildConfig.VERSION_NAME) title = getString(R.string.app_version, BuildConfig.VERSION_NAME)
isEnabled = isUpdateSupported isEnabled = viewModel.isUpdateSupported
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.isLoading.observe(viewLifecycleOwner) {
findPreference<Preference>(AppSettings.KEY_APP_UPDATE)?.isEnabled = !it
} }
viewModel.onUpdateAvailable.observe(viewLifecycleOwner, ::onUpdateAvailable)
} }
override fun onPreferenceTreeClick(preference: Preference): Boolean { override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) { return when (preference.key) {
AppSettings.KEY_APP_VERSION -> { AppSettings.KEY_APP_VERSION -> {
checkForUpdates() viewModel.checkForUpdates()
true true
} }
AppSettings.KEY_APP_TRANSLATION -> { AppSettings.KEY_APP_TRANSLATION -> {
@ -40,24 +47,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
} }
} }
private fun checkForUpdates() { private fun onUpdateAvailable(version: AppVersion?) {
viewLifecycleScope.launch { if (version == null) {
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run { Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
setSummary(R.string.checking_for_updates) return
isSelectable = false
}
val result = AppUpdateChecker(activity ?: return@launch).checkNow()
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
setSummary(
when (result) {
true -> R.string.check_for_updates
false -> R.string.no_update_available
null -> R.string.update_check_failed
}
)
isSelectable = true
}
} }
AppUpdateDialog(context ?: return).show(version)
} }
private fun openLink(url: String, title: CharSequence?) { private fun openLink(url: String, title: CharSequence?) {
@ -68,7 +63,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
Intent.createChooser(intent, title) Intent.createChooser(intent, title)
} else { } else {
intent intent
} },
) )
} }
} }

@ -0,0 +1,24 @@
package org.koitharu.kotatsu.settings.about
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.utils.SingleLiveEvent
@HiltViewModel
class AboutSettingsViewModel @Inject constructor(
private val appUpdateRepository: AppUpdateRepository,
) : BaseViewModel() {
val isUpdateSupported = appUpdateRepository.isUpdateSupported()
val onUpdateAvailable = SingleLiveEvent<AppVersion?>()
fun checkForUpdates() {
launchLoadingJob {
val update = appUpdateRepository.fetchUpdate()
onUpdateAvailable.call(update)
}
}
}

@ -0,0 +1,39 @@
package org.koitharu.kotatsu.settings.about
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.utils.FileSize
class AppUpdateDialog(private val context: Context) {
fun show(version: AppVersion) {
val message = buildString {
append(context.getString(R.string.new_version_s, version.name))
appendLine()
append(context.getString(R.string.size_s, FileSize.BYTES.format(context, version.apkSize)))
appendLine()
appendLine()
append(version.description)
}
MaterialAlertDialogBuilder(
context,
materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
)
.setTitle(R.string.app_update_available)
.setMessage(message)
.setIcon(R.drawable.ic_app_update)
.setPositiveButton(R.string.download) { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri())
context.startActivity(Intent.createChooser(intent, context.getString(R.string.open_in_browser)))
}
.setNegativeButton(R.string.close, null)
.setCancelable(false)
.create()
.show()
}
}

@ -51,8 +51,9 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
viewModel.onError.observe(viewLifecycleOwner, this::onError) viewModel.onError.observe(viewLifecycleOwner, this::onError)
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setCancelable(false) return super.onBuildDialog(builder)
.setCancelable(false)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
} }

@ -44,8 +44,9 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
viewModel.onError.observe(viewLifecycleOwner, this::onError) viewModel.onError.observe(viewLifecycleOwner, this::onError)
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder.setCancelable(false) return super.onBuildDialog(builder)
.setCancelable(false)
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {

@ -43,8 +43,8 @@ class NewSourcesDialogFragment :
viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it } viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it }
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder return super.onBuildDialog(builder)
.setPositiveButton(R.string.done, this) .setPositiveButton(R.string.done, this)
.setCancelable(true) .setCancelable(true)
.setTitle(R.string.remote_sources) .setTitle(R.string.remote_sources)

@ -39,8 +39,8 @@ class OnboardDialogFragment :
container: ViewGroup?, container: ViewGroup?,
) = DialogOnboardBinding.inflate(inflater, container, false) ) = DialogOnboardBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
builder super.onBuildDialog(builder)
.setPositiveButton(R.string.done, this) .setPositiveButton(R.string.done, this)
.setCancelable(true) .setCancelable(true)
if (isWelcome) { if (isWelcome) {
@ -50,6 +50,7 @@ class OnboardDialogFragment :
.setTitle(R.string.remote_sources) .setTitle(R.string.remote_sources)
.setNegativeButton(android.R.string.cancel, this) .setNegativeButton(android.R.string.cancel, this)
} }
return builder
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

@ -120,7 +120,7 @@ class SourcesSettingsFragment :
else -> false else -> false
} }
override fun onMenuItemActionExpand(item: MenuItem?): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true) (activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
return true return true
} }

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.databinding.FragmentToolsBinding import org.koitharu.kotatsu.databinding.FragmentToolsBinding
import org.koitharu.kotatsu.download.ui.DownloadsActivity import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.tools.model.StorageUsage import org.koitharu.kotatsu.settings.tools.model.StorageUsage
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
@ -68,6 +69,10 @@ class ToolsFragment :
intent.data = url.toUri() intent.data = url.toUri()
startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser))) startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser)))
} }
R.id.card_update -> {
val version = viewModel.appUpdate.value ?: return
AppUpdateDialog(v.context).show(version)
}
} }
} }

@ -15,7 +15,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
@HiltViewModel @HiltViewModel
@ -44,8 +44,8 @@ class SuggestionsViewModel @Inject constructor(
}.onFirst { }.onFirst {
loadingCounter.decrement() loadingCounter.decrement()
}.catch { }.catch {
it.toErrorState(canRetry = false) emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct( }.asFlowLiveData(
viewModelScope.coroutineContext + Dispatchers.Default, viewModelScope.coroutineContext + Dispatchers.Default,
listOf(LoadingState), listOf(LoadingState),
) )

@ -5,9 +5,26 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem( fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
id = trackLog.id, val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }, return TrackingLogItem(
manga = manga.toManga(tags.toMangaTags()), id = trackLog.id,
createdAt = Date(trackLog.createdAt) chapters = chaptersList,
) manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt),
isNew = counters.decrement(trackLog.mangaId, chaptersList.size),
)
}
private fun MutableMap<Long, Int>.decrement(key: Long, count: Int): Boolean {
val counter = get(key)
if (counter == null || counter <= 0) {
return false
}
if (counter < count) {
remove(key)
} else {
put(key, counter - count)
}
return true
}

@ -18,6 +18,10 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int? abstract suspend fun findNewChapters(mangaId: Long): Int?
@MapInfo(keyColumn = "manga_id", valueColumn = "chapters_new")
@Query("SELECT manga_id, chapters_new FROM tracks")
abstract fun observeNewChaptersMap(): Flow<Map<Long, Int>>
@Query("SELECT chapters_new FROM tracks") @Query("SELECT chapters_new FROM tracks")
abstract fun observeNewChapters(): Flow<List<Int>> abstract fun observeNewChapters(): Flow<List<Int>>

@ -5,6 +5,8 @@ import androidx.room.withTransaction
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
@ -73,9 +75,15 @@ class TrackingRepository @Inject constructor(
db.tracksDao.delete(mangaId) db.tracksDao.delete(mangaId)
} }
suspend fun getTrackingLog(offset: Int, limit: Int): List<TrackingLogItem> { fun observeTrackingLog(limit: Flow<Int>): Flow<List<TrackingLogItem>> {
return db.trackLogsDao.findAll(offset, limit).map { x -> return limit.flatMapLatest { limitValue ->
x.toTrackingLogItem() combine(
db.tracksDao.observeNewChaptersMap(),
db.trackLogsDao.observeAll(limitValue),
) { counters, entities ->
val countersMap = counters.toMutableMap()
entities.map { x -> x.toTrackingLogItem(countersMap) }
}
} }
} }

@ -7,5 +7,6 @@ data class TrackingLogItem(
val id: Long, val id: Long,
val manga: Manga, val manga: Manga,
val chapters: List<String>, val chapters: List<String>,
val createdAt: Date val createdAt: Date,
) val isNew: Boolean,
)

@ -137,7 +137,7 @@ class FeedFragment :
} }
override fun onScrolledToEnd() { override fun onScrolledToEnd() {
viewModel.loadList(append = true) viewModel.requestMoreItems()
} }
override fun onItemClick(item: Manga, view: View) { override fun onItemClick(item: Manga, view: View) {

@ -4,44 +4,39 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
private const val PAGE_SIZE = 20
@HiltViewModel @HiltViewModel
class FeedViewModel @Inject constructor( class FeedViewModel @Inject constructor(
private val repository: TrackingRepository, private val repository: TrackingRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null) private val limit = MutableStateFlow(PAGE_SIZE)
private val hasNextPage = MutableStateFlow(false) private val isReady = AtomicBoolean(false)
private var loadingJob: Job? = null
val onFeedCleared = SingleLiveEvent<Unit>() val onFeedCleared = SingleLiveEvent<Unit>()
val content = combine( val content = repository.observeTrackingLog(limit)
logList.filterNotNull(), .map { list ->
hasNextPage,
) { list, isHasNextPage ->
buildList(list.size + 2) {
if (list.isEmpty()) { if (list.isEmpty()) {
add( listOf(
EmptyState( EmptyState(
icon = R.drawable.ic_empty_feed, icon = R.drawable.ic_empty_feed,
textPrimary = R.string.text_empty_holder_primary, textPrimary = R.string.text_empty_holder_primary,
@ -50,48 +45,26 @@ class FeedViewModel @Inject constructor(
), ),
) )
} else { } else {
list.mapListTo(this) isReady.set(true)
if (isHasNextPage) { list.mapList()
add(LoadingFooter)
}
} }
} }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
loadList(append = false)
}
fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return
}
if (append && !hasNextPage.value) {
return
}
loadingJob = launchLoadingJob(Dispatchers.Default) {
val offset = if (append) logList.value?.size ?: 0 else 0
val list = repository.getTrackingLog(offset, 20)
if (!append) {
logList.value = list
} else if (list.isNotEmpty()) {
logList.value = logList.value?.plus(list) ?: list
}
hasNextPage.value = list.isNotEmpty()
}
}
fun clearFeed() { fun clearFeed() {
val lastJob = loadingJob launchLoadingJob(Dispatchers.Default) {
loadingJob = launchLoadingJob(Dispatchers.Default) {
lastJob?.cancelAndJoin()
repository.clearLogs() repository.clearLogs()
logList.value = emptyList()
onFeedCleared.postCall(Unit) onFeedCleared.postCall(Unit)
} }
} }
private fun List<TrackingLogItem>.mapListTo(destination: MutableList<ListModel>) { fun requestMoreItems() {
if (isReady.compareAndSet(true, false)) {
limit.value += PAGE_SIZE
}
}
private fun List<TrackingLogItem>.mapList(): List<ListModel> {
val destination = ArrayList<ListModel>((size * 1.4).toInt())
var prevDate: DateTimeAgo? = null var prevDate: DateTimeAgo? = null
for (item in this) { for (item in this) {
val date = timeAgo(item.createdAt) val date = timeAgo(item.createdAt)
@ -101,6 +74,7 @@ class FeedViewModel @Inject constructor(
prevDate = date prevDate = date
destination += item.toFeedItem() destination += item.toFeedItem()
} }
return destination
} }
private fun timeAgo(date: Date): DateTimeAgo { private fun timeAgo(date: Date): DateTimeAgo {

@ -11,22 +11,23 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.ui.model.FeedItem import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.isBold
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
fun feedItemAD( fun feedItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga> clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>( ) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>(
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) },
) { ) {
itemView.setOnClickListener { itemView.setOnClickListener {
clickListener.onItemClick(item.manga, it) clickListener.onItemClick(item.manga, it)
} }
bind { bind {
binding.textViewTitle.isBold = item.isNew
binding.textViewSummary.isBold = item.isNew
binding.imageViewCover.newImageRequest(item.imageUrl)?.run { binding.imageViewCover.newImageRequest(item.imageUrl)?.run {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)

@ -9,4 +9,5 @@ data class FeedItem(
val title: String, val title: String,
val manga: Manga, val manga: Manga,
val count: Int, val count: Int,
) : ListModel val isNew: Boolean,
) : ListModel

@ -8,4 +8,5 @@ fun TrackingLogItem.toFeedItem() = FeedItem(
title = manga.title, title = manga.title,
count = chapters.size, count = chapters.size,
manga = manga, manga = manga,
) isNew = isNew,
)

@ -7,7 +7,7 @@ import kotlin.math.roundToInt
class GridTouchHelper( class GridTouchHelper(
context: Context, context: Context,
private val listener: OnGridTouchListener private val listener: OnGridTouchListener,
) : GestureDetector.SimpleOnGestureListener() { ) : GestureDetector.SimpleOnGestureListener() {
private val detector = GestureDetector(context, this) private val detector = GestureDetector(context, this)
@ -16,7 +16,7 @@ class GridTouchHelper(
private var isDispatching = false private var isDispatching = false
init { init {
detector.setIsLongpressEnabled(false) detector.setIsLongpressEnabled(true)
detector.setOnDoubleTapListener(this) detector.setOnDoubleTapListener(this)
} }
@ -46,7 +46,7 @@ class GridTouchHelper(
} }
2 -> AREA_RIGHT 2 -> AREA_RIGHT
else -> return false else -> return false
} },
) )
return true return true
} }
@ -66,4 +66,4 @@ class GridTouchHelper(
fun onProcessTouch(rawX: Int, rawY: Int): Boolean fun onProcessTouch(rawX: Int, rawY: Int): Boolean
} }
} }

@ -0,0 +1,37 @@
package org.koitharu.kotatsu.utils
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class IdlingDetector(
private val timeoutMs: Long,
private val callback: Callback,
) : DefaultLifecycleObserver {
private val handler = Handler(Looper.getMainLooper())
private val idleRunnable = Runnable {
callback.onIdle()
}
fun bindToLifecycle(owner: LifecycleOwner) {
owner.lifecycle.addObserver(this)
}
fun onUserInteraction() {
handler.removeCallbacks(idleRunnable)
handler.postDelayed(idleRunnable, timeoutMs)
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
owner.lifecycle.removeObserver(this)
handler.removeCallbacks(idleRunnable)
}
fun interface Callback {
fun onIdle()
}
}

@ -0,0 +1,46 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import java.io.Serializable
@Suppress("DEPRECATION")
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, T::class.java)
} else {
getParcelable(key) as? T
}
}
@Suppress("DEPRECATION")
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(key, T::class.java)
} else {
getParcelableExtra(key) as? T
}
}
@Suppress("DEPRECATION")
inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getSerializable(key, T::class.java)
} else {
getSerializable(key) as? T
}
}
inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T {
return checkNotNull(getSerializableCompat(key)) {
"Serializable of type \"${T::class.java.name}\" not found at \"$key\""
}
}
inline fun <reified T : Parcelable> Bundle.requireParcelable(key: String): T {
return checkNotNull(getParcelableCompat(key)) {
"Parcelable of type \"${T::class.java.name}\" not found at \"$key\""
}
}

@ -10,6 +10,7 @@ import coil.request.ImageResult
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
@ -47,7 +48,18 @@ fun ImageResult.toBitmapOrNull() = when (this) {
} }
fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder { fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
return setHeader(CommonHeaders.REFERER, referer) if (referer.isEmpty()) {
return this
}
try {
setHeader(CommonHeaders.REFERER, referer)
} catch (e: IllegalArgumentException) {
val baseUrl = referer.baseUrl()
if (baseUrl != null) {
setHeader(CommonHeaders.REFERER, baseUrl)
}
}
return this
} }
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder { fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
@ -63,3 +75,11 @@ fun ImageRequest.Builder.crossfade(context: Context?): ImageRequest.Builder {
val duration = context.resources.getInteger(R.integer.config_defaultAnimTime) * context.animatorDurationScale val duration = context.resources.getInteger(R.integer.config_defaultAnimTime) * context.animatorDurationScale
return crossfade(duration.toInt()) return crossfade(duration.toInt())
} }
private fun String.baseUrl(): String? {
return (this.toHttpUrlOrNull()?.newBuilder("/") ?: return null)
.username("")
.password("")
.build()
.toString()
}

@ -1,14 +1,17 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import java.io.Serializable import java.io.Serializable
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T { inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val b = Bundle(size) val b = Bundle(size)
@ -20,18 +23,10 @@ inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val Fragment.viewLifecycleScope val Fragment.viewLifecycleScope
inline get() = viewLifecycleOwner.lifecycle.coroutineScope inline get() = viewLifecycleOwner.lifecycle.coroutineScope
fun <T : Parcelable> Fragment.parcelableArgument(name: String): Lazy<T> {
return lazy(LazyThreadSafetyMode.NONE) {
requireNotNull(arguments?.getParcelable(name)) {
"No argument $name passed into ${javaClass.simpleName}"
}
}
}
fun <T : Serializable> Fragment.serializableArgument(name: String): Lazy<T> { fun <T : Serializable> Fragment.serializableArgument(name: String): Lazy<T> {
return lazy(LazyThreadSafetyMode.NONE) { return lazy(LazyThreadSafetyMode.NONE) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
requireNotNull(arguments?.getSerializable(name)) { requireNotNull(arguments?.getSerializableCompat(name)) {
"No argument $name passed into ${javaClass.simpleName}" "No argument $name passed into ${javaClass.simpleName}"
} as T } as T
} }
@ -50,3 +45,19 @@ fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
fun Fragment.addMenuProvider(provider: MenuProvider) { fun Fragment.addMenuProvider(provider: MenuProvider) {
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.STARTED) requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.STARTED)
} }
suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner = suspendCancellableCoroutine { cont ->
val liveData = viewLifecycleOwnerLiveData
val observer = object : Observer<LifecycleOwner> {
override fun onChanged(result: LifecycleOwner?) {
if (result != null) {
liveData.removeObserver(this)
cont.resume(result)
}
}
}
liveData.observeForever(observer)
cont.invokeOnCancellation {
liveData.removeObserver(observer)
}
}

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.graphics.Typeface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.view.View import android.view.View
import android.widget.TextView import android.widget.TextView
@ -48,3 +49,15 @@ fun TextView.setTextAndVisible(@StringRes textResId: Int) {
fun TextView.setTextColorAttr(@AttrRes attrResId: Int) { fun TextView.setTextColorAttr(@AttrRes attrResId: Int) {
setTextColor(context.getThemeColorStateList(attrResId)) setTextColor(context.getThemeColorStateList(attrResId))
} }
var TextView.isBold: Boolean
get() = typeface.isBold
set(value) {
var style = typeface.style
style = if (value) {
style or Typeface.BOLD
} else {
style and Typeface.BOLD.inv()
}
setTypeface(typeface, style)
}

@ -2,11 +2,14 @@ package org.koitharu.kotatsu.utils.ext
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.widget.TextView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.annotation.StyleRes
import androidx.core.content.res.use import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat
fun Context.getThemeDrawable( fun Context.getThemeDrawable(
@AttrRes resId: Int, @AttrRes resId: Int,
@ -43,3 +46,9 @@ fun Context.getThemeColorStateList(
) = obtainStyledAttributes(intArrayOf(resId)).use { ) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getColorStateList(0) it.getColorStateList(0)
} }
fun TextView.setThemeTextAppearance(@AttrRes resId: Int, @StyleRes fallback: Int) {
context.obtainStyledAttributes(intArrayOf(resId)).use {
TextViewCompat.setTextAppearance(this, it.getResourceId(0, fallback))
}
}

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity import android.app.Activity
import android.graphics.Rect import android.graphics.Rect
import android.view.View import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewParent import android.view.ViewParent
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@ -167,3 +168,19 @@ val View.parents: Sequence<ViewParent>
p = p.parent p = p.parent
} }
} }
@Suppress("unused")
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
if (specMode == MeasureSpec.EXACTLY) {
result = specSize
} else {
result = desiredSize
if (specMode == MeasureSpec.AT_MOST) {
result = result.coerceAtMost(specSize)
}
}
return result
}

@ -181,11 +181,11 @@
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="8dp" android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="16dp" android:layout_marginEnd="@dimen/margin_normal"
android:layout_marginBottom="16dp"
android:lineSpacingMultiplier="1.2" android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true" android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

@ -63,56 +63,66 @@
app:layout_constraintWidth_percent="0.5" app:layout_constraintWidth_percent="0.5"
tools:layout="@layout/fragment_details" /> tools:layout="@layout/fragment_details" />
<ImageView <RelativeLayout
android:id="@+id/button_dropdown" android:id="@+id/group_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:padding="@dimen/margin_small"
android:src="@drawable/ic_expand_more"
app:layout_constraintBottom_toTopOf="@id/divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/margin_normal" app:layout_constraintEnd_toEndOf="parent"
android:singleLine="true"
android:textAppearance="?textAppearanceTitleMedium"
app:layout_constraintBottom_toTopOf="@id/textView_subtitle"
app:layout_constraintEnd_toStartOf="@id/button_dropdown"
app:layout_constraintStart_toStartOf="@id/container_chapters" app:layout_constraintStart_toStartOf="@id/container_chapters"
app:layout_constraintTop_toBottomOf="@id/appbar" app:layout_constraintTop_toBottomOf="@id/appbar">
app:layout_constraintVertical_chainStyle="packed"
tools:text="@string/chapter_d_of_d" />
<TextView <TextView
android:id="@+id/textView_subtitle" android:id="@+id/textView_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/margin_normal" android:layout_alignWithParentIfMissing="true"
android:singleLine="true" android:layout_alignParentStart="true"
android:textAppearance="?textAppearanceTitleSmall" android:layout_alignParentTop="true"
android:textColor="?android:textColorSecondary" android:layout_marginTop="4dp"
android:visibility="gone" android:layout_toStartOf="@id/button_dropdown"
app:layout_constraintBottom_toTopOf="@id/divider" android:paddingHorizontal="@dimen/margin_normal"
app:layout_constraintEnd_toStartOf="@id/button_dropdown" android:singleLine="true"
app:layout_constraintStart_toStartOf="@id/container_chapters" android:textAppearance="?textAppearanceTitleMedium"
app:layout_constraintTop_toBottomOf="@id/textView_title" tools:text="@string/chapter_d_of_d" />
tools:text="English"
tools:visibility="visible" />
<View <TextView
android:id="@+id/divider" android:id="@+id/textView_subtitle"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="1dp" android:layout_height="wrap_content"
android:layout_marginTop="48dp" android:layout_alignWithParentIfMissing="true"
android:background="?colorSecondaryContainer" android:layout_below="@id/textView_title"
app:layout_constraintEnd_toEndOf="parent" android:layout_alignParentStart="true"
app:layout_constraintStart_toEndOf="@id/container_details" android:layout_toStartOf="@id/button_dropdown"
app:layout_constraintTop_toBottomOf="@id/appbar" /> android:paddingHorizontal="@dimen/margin_normal"
android:singleLine="true"
android:textAppearance="?textAppearanceTitleSmall"
android:textColor="?android:textColorSecondary"
android:visibility="gone"
tools:text="English"
tools:visibility="visible" />
<ImageView
android:id="@+id/button_dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginBottom="1dp"
android:background="?selectableItemBackgroundBorderless"
android:padding="@dimen/margin_small"
android:src="@drawable/ic_expand_more" />
<View
android:id="@+id/divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_below="@id/textView_subtitle"
android:layout_alignParentStart="true"
android:layout_alignParentEnd="true"
android:layout_marginTop="4dp"
android:background="?colorSecondaryContainer" />
</RelativeLayout>
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/container_chapters" android:id="@+id/container_chapters"
@ -122,7 +132,7 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/container_details" app:layout_constraintStart_toEndOf="@id/container_details"
app:layout_constraintTop_toBottomOf="@id/divider" app:layout_constraintTop_toBottomOf="@id/group_header"
tools:layout="@layout/fragment_chapters" /> tools:layout="@layout/fragment_chapters" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

@ -42,9 +42,11 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:visibility="gone"
app:behavior_hideable="false" app:behavior_hideable="false"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.BottomSheet"> app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.BottomSheet"
tools:visibility="visible">
<org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar <org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
android:id="@+id/header_chapters" android:id="@+id/header_chapters"

@ -1,13 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
android:id="@+id/ssiv" android:id="@+id/ssiv"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent"
app:restoreStrategy="deferred" />
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
@ -21,4 +23,4 @@
android:layout_gravity="center" android:layout_gravity="center"
android:indeterminate="true" /> android:indeterminate="true" />
</FrameLayout> </FrameLayout>

@ -28,9 +28,9 @@
<org.koitharu.kotatsu.reader.ui.ReaderInfoBarView <org.koitharu.kotatsu.reader.ui.ReaderInfoBarView
android:id="@+id/infoBar" android:id="@+id/infoBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="16dp" android:layout_height="wrap_content"
android:layout_gravity="top" android:layout_gravity="top"
android:paddingHorizontal="4dp" android:minHeight="12sp"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="@dimen/margin_normal"
android:paddingTop="@dimen/margin_normal">
<TextView
android:id="@+id/textView_message"
style="@style/MaterialAlertDialog.Material3.Body.Text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:linksClickable="true"
tools:text="@tools:sample/lorem[20]" />
</LinearLayout>
</ScrollView>

@ -193,11 +193,11 @@
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="8dp" android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="16dp" android:layout_marginEnd="@dimen/margin_normal"
android:layout_marginBottom="16dp"
android:lineSpacingMultiplier="1.2" android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true" android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
@ -9,7 +10,8 @@
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
android:id="@+id/ssiv" android:id="@+id/ssiv"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent"
app:restoreStrategy="deferred" />
<TextView <TextView
android:id="@+id/textView_number" android:id="@+id/textView_number"
@ -24,4 +26,4 @@
<include layout="@layout/layout_page_info" /> <include layout="@layout/layout_page_info" />
</FrameLayout> </FrameLayout>

@ -2,7 +2,6 @@
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/card_update" android:id="@+id/card_update"
style="@style/Widget.Material3.CardView.Filled" style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -53,7 +52,7 @@
style="@style/Widget.Material3.Button" style="@style/Widget.Material3.Button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small" android:layout_marginTop="12dp"
android:text="@string/download" android:text="@string/download"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/textSecondary" /> app:layout_constraintTop_toBottomOf="@id/textSecondary" />
@ -61,4 +60,4 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>

@ -62,6 +62,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:background="?selectableItemBackgroundBorderless"
android:drawablePadding="4dp" android:drawablePadding="4dp"
android:gravity="center" android:gravity="center"
android:padding="4dp" android:padding="4dp"

@ -6,27 +6,20 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
tools:parentTag="com.google.android.material.appbar.AppBarLayout"> tools:parentTag="com.google.android.material.appbar.AppBarLayout">
<FrameLayout <com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/frame" android:id="@+id/dragHandle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"> android:layout_height="@dimen/bottom_sheet_handle_size_min"
android:minHeight="0dp"
android:paddingTop="12dp"
android:paddingBottom="0dp" />
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar" android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar"
tools:navigationIcon="?actionModeCloseDrawable" tools:navigationIcon="?actionModeCloseDrawable"
tools:title="@string/options" /> tools:title="@string/options" />
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/dragHandle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:paddingTop="12dp"
android:paddingBottom="0dp" />
</FrameLayout>
</merge> </merge>

@ -9,7 +9,7 @@
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp"
android:layout_marginVertical="8dp" android:layout_marginVertical="8dp"
android:paddingBottom="8dp" android:paddingBottom="8dp"
app:cardBackgroundColor="?colorOnPrimary" app:cardBackgroundColor="?colorPrimaryContainer"
app:cardCornerRadius="24dp"> app:cardCornerRadius="24dp">
<LinearLayout <LinearLayout

@ -103,7 +103,7 @@
android:text="@string/reader_mode_hint" android:text="@string/reader_mode_hint"
android:textAppearance="?attr/textAppearanceBodySmall" /> android:textAppearance="?attr/textAppearanceBodySmall" />
<LinearLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal" android:layout_marginTop="@dimen/margin_normal"
@ -115,23 +115,38 @@
android:paddingEnd="?android:listPreferredItemPaddingEnd"> android:paddingEnd="?android:listPreferredItemPaddingEnd">
<TextView <TextView
android:layout_width="wrap_content" android:id="@+id/textView_timer"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart" android:drawablePadding="?android:listPreferredItemPaddingStart"
android:ellipsize="end"
android:singleLine="true"
android:text="@string/automatic_scroll" android:text="@string/automatic_scroll"
android:textAppearance="?attr/textAppearanceButton" android:textAppearance="?attr/textAppearanceButton"
app:drawableStartCompat="@drawable/ic_timer" /> app:drawableStartCompat="@drawable/ic_timer"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/slider_timer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_default="wrap" />
<com.google.android.material.slider.Slider <com.google.android.material.slider.Slider
android:id="@+id/slider_timer" android:id="@+id/slider_timer"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal" android:layout_marginStart="@dimen/margin_normal"
android:contentDescription="@string/automatic_scroll"
android:labelFor="@id/textView_timer"
android:valueFrom="0" android:valueFrom="0"
android:valueTo="20" android:valueTo="20"
app:labelBehavior="floating" /> app:labelBehavior="floating"
app:layout_constraintBottom_toBottomOf="parent"
</LinearLayout> app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/textView_timer"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_min="120dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView <org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
android:id="@+id/button_color_filter" android:id="@+id/button_color_filter"

@ -288,4 +288,42 @@
<string name="reverse">Balik</string> <string name="reverse">Balik</string>
<string name="queued">Diantrikan</string> <string name="queued">Diantrikan</string>
<string name="auth_complete">Berhasil Diotorisasi</string> <string name="auth_complete">Berhasil Diotorisasi</string>
<string name="text_local_holder_secondary">Simpan dari sumber daring atau berkas impor.</string>
<string name="text_shelf_holder_primary">Manga Anda akan ditampilkan di sini</string>
<string name="text_shelf_holder_secondary">Cari apa untuk dibaca di bagian «Jelajah»</string>
<string name="email_enter_hint">Masukkan surel Anda untuk melanjutkan</string>
<string name="status_re_reading">Dibaca ulang</string>
<string name="explore">Jelajah</string>
<string name="status_planned">Direncanakan</string>
<string name="status_completed">Selesai</string>
<string name="canceled">Dibatalkan</string>
<string name="sync_title">Sinkronisasi data Anda</string>
<string name="enter_email_text">Masukkan surel Anda untuk melanjutkan</string>
<string name="tracking">Pelacakan</string>
<string name="logout">Keluar</string>
<string name="sync">Sinkronisasi</string>
<string name="send">Terkirim</string>
<string name="status_reading">Dibaca</string>
<string name="status_on_hold">Ditunda</string>
<string name="invalid_domain_message">Domain tidak valid</string>
<string name="no_bookmarks_yet">Belum ada markah</string>
<string name="no_bookmarks_summary">Anda bisa membuat markah ketika membaca manga</string>
<string name="bookmarks_removed">Markah dihapus</string>
<string name="no_manga_sources">Tidak ada sumber manga</string>
<string name="random">Acak</string>
<string name="empty">Kosong</string>
<string name="changelog">Daftar Perubahan</string>
<string name="removed_from_s">Dihapus dari \"%s\"</string>
<string name="reader_info_bar">Tampilkan bilah informasi di pembaca</string>
<string name="importing_manga">Mengimpor manga</string>
<string name="last_2_hours">Dua jam terakhir</string>
<string name="downloading_manga">Mengunduh manga</string>
<string name="show_all">Tampilkan semua</string>
<string name="history_cleared">Riwayat dihapus</string>
<string name="clear_all_history">Hapus semua riwayat</string>
<string name="options">Pilihan</string>
<string name="import_completed">Impor selesai</string>
<string name="removed_from_favourites">Dihapus dari favorit</string>
<string name="import_will_start_soon">Impor akan segera dimulai</string>
<string name="not_found_404">Konten tidak ditemukan atau dihapus</string>
</resources> </resources>

@ -372,4 +372,6 @@
<string name="reader_info_pattern">Ch.%1$d/%2$d Pg.%3$d/%4$d</string> <string name="reader_info_pattern">Ch.%1$d/%2$d Pg.%3$d/%4$d</string>
<string name="reader_info_bar">リーダーで情報バーを表示する</string> <string name="reader_info_bar">リーダーで情報バーを表示する</string>
<string name="comics_archive">コミックアーカイブ</string> <string name="comics_archive">コミックアーカイブ</string>
<string name="downloading_manga">漫画をダウンロードする</string>
<string name="feed">フィード</string>
</resources> </resources>

@ -1,375 +1,377 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="close_menu">Закрыть меню</string> <string name="close_menu">Закрыть меню</string>
<string name="open_menu">Открыть меню</string> <string name="open_menu">Открыть меню</string>
<string name="local_storage">На устройстве</string> <string name="local_storage">На устройстве</string>
<string name="favourites">Избранное</string> <string name="favourites">Избранное</string>
<string name="history">История</string> <string name="history">История</string>
<string name="error_occurred">Произошла ошибка</string> <string name="error_occurred">Произошла ошибка</string>
<string name="network_error">Не удалось подключиться к интернету</string> <string name="network_error">Не удалось подключиться к интернету</string>
<string name="details">Подробности</string> <string name="details">Подробности</string>
<string name="chapters">Главы</string> <string name="chapters">Главы</string>
<string name="list">Список</string> <string name="list">Список</string>
<string name="detailed_list">Подробный список</string> <string name="detailed_list">Подробный список</string>
<string name="grid">Таблица</string> <string name="grid">Таблица</string>
<string name="list_mode">Вид списка</string> <string name="list_mode">Вид списка</string>
<string name="settings">Настройки</string> <string name="settings">Настройки</string>
<string name="remote_sources">Онлайн каталоги</string> <string name="remote_sources">Онлайн каталоги</string>
<string name="loading_">Загрузка…</string> <string name="loading_">Загрузка…</string>
<string name="chapter_d_of_d">Глава %1$d из %2$d</string> <string name="chapter_d_of_d">Глава %1$d из %2$d</string>
<string name="close">Закрыть</string> <string name="close">Закрыть</string>
<string name="try_again">Повторить</string> <string name="try_again">Повторить</string>
<string name="clear_history">Очистить историю</string> <string name="clear_history">Очистить историю</string>
<string name="nothing_found">Ничего не найдено</string> <string name="nothing_found">Ничего не найдено</string>
<string name="history_is_empty">Истории пока нет</string> <string name="history_is_empty">Истории пока нет</string>
<string name="read">Читать</string> <string name="read">Читать</string>
<string name="you_have_not_favourites_yet">Избранного пока нет</string> <string name="you_have_not_favourites_yet">Избранного пока нет</string>
<string name="add_to_favourites">В избранное</string> <string name="add_to_favourites">В избранное</string>
<string name="add_new_category">Новая категория</string> <string name="add_new_category">Новая категория</string>
<string name="add">Добавить</string> <string name="add">Добавить</string>
<string name="enter_category_name">Введите название</string> <string name="enter_category_name">Введите название</string>
<string name="save">Сохранить</string> <string name="save">Сохранить</string>
<string name="share">Поделиться</string> <string name="share">Поделиться</string>
<string name="create_shortcut">Создать ярлык…</string> <string name="create_shortcut">Создать ярлык…</string>
<string name="share_s">Поделиться %s</string> <string name="share_s">Поделиться %s</string>
<string name="search">Поиск</string> <string name="search">Поиск</string>
<string name="search_manga">Поиск манги</string> <string name="search_manga">Поиск манги</string>
<string name="manga_downloading_">Загрузка…</string> <string name="manga_downloading_">Загрузка…</string>
<string name="processing_">Обработка…</string> <string name="processing_">Обработка…</string>
<string name="download_complete">Загружено</string> <string name="download_complete">Загружено</string>
<string name="downloads">Загрузки</string> <string name="downloads">Загрузки</string>
<string name="by_name">Имя</string> <string name="by_name">Имя</string>
<string name="popular">Популярная</string> <string name="popular">Популярная</string>
<string name="updated">Обновлённая</string> <string name="updated">Обновлённая</string>
<string name="newest">Новая</string> <string name="newest">Новая</string>
<string name="by_rating">Рейтинг</string> <string name="by_rating">Рейтинг</string>
<string name="sort_order">Порядок сортировки</string> <string name="sort_order">Порядок сортировки</string>
<string name="filter">Фильтр</string> <string name="filter">Фильтр</string>
<string name="theme">Тема</string> <string name="theme">Тема</string>
<string name="light">Светлая</string> <string name="light">Светлая</string>
<string name="dark">Тёмная</string> <string name="dark">Тёмная</string>
<string name="automatic">Как в системе</string> <string name="automatic">Как в системе</string>
<string name="pages">Страницы</string> <string name="pages">Страницы</string>
<string name="clear">Очистить</string> <string name="clear">Очистить</string>
<string name="text_clear_history_prompt">Очистить всю историю чтения полностью\?</string> <string name="text_clear_history_prompt">Очистить всю историю чтения полностью\?</string>
<string name="remove">Удалить</string> <string name="remove">Удалить</string>
<string name="_s_removed_from_history">«%s» удалено из истории</string> <string name="_s_removed_from_history">«%s» удалено из истории</string>
<string name="_s_deleted_from_local_storage">«%s» удалено с устройства</string> <string name="_s_deleted_from_local_storage">«%s» удалено с устройства</string>
<string name="wait_for_loading_finish">Дождитесь завершения загрузки…</string> <string name="wait_for_loading_finish">Дождитесь завершения загрузки…</string>
<string name="save_page">Сохранить страницу</string> <string name="save_page">Сохранить страницу</string>
<string name="page_saved">Сохранено</string> <string name="page_saved">Сохранено</string>
<string name="share_image">Поделиться изображением</string> <string name="share_image">Поделиться изображением</string>
<string name="_import">Импорт</string> <string name="_import">Импорт</string>
<string name="delete">Удалить</string> <string name="delete">Удалить</string>
<string name="operation_not_supported">Операция не поддерживается</string> <string name="operation_not_supported">Операция не поддерживается</string>
<string name="text_file_not_supported">Выберите файл в формате CBZ или ZIP.</string> <string name="text_file_not_supported">Выберите файл в формате CBZ или ZIP.</string>
<string name="no_description">Описание отсутствует</string> <string name="no_description">Описание отсутствует</string>
<string name="history_and_cache">История и кэш</string> <string name="history_and_cache">История и кэш</string>
<string name="clear_pages_cache">Очистить кэш страниц</string> <string name="clear_pages_cache">Очистить кэш страниц</string>
<string name="cache">Кэш</string> <string name="cache">Кэш</string>
<string name="text_file_sizes">Б|кБ|МБ|ГБ|ТБ</string> <string name="text_file_sizes">Б|кБ|МБ|ГБ|ТБ</string>
<string name="standard">Стандартный</string> <string name="standard">Стандартный</string>
<string name="webtoon">Манхва</string> <string name="webtoon">Манхва</string>
<string name="read_mode">Режим чтения</string> <string name="read_mode">Режим чтения</string>
<string name="grid_size">Размер сетки</string> <string name="grid_size">Размер сетки</string>
<string name="search_on_s">Поиск по %s</string> <string name="search_on_s">Поиск по %s</string>
<string name="delete_manga">Удалить мангу</string> <string name="delete_manga">Удалить мангу</string>
<string name="text_delete_local_manga">Удалить \"%s\" с устройства навсегда\?</string> <string name="text_delete_local_manga">Удалить \"%s\" с устройства навсегда\?</string>
<string name="reader_settings">Настройки режима чтения</string> <string name="reader_settings">Настройки режима чтения</string>
<string name="switch_pages">Листание страниц</string> <string name="switch_pages">Листание страниц</string>
<string name="taps_on_edges">Нажатия по краям</string> <string name="taps_on_edges">Нажатия по краям</string>
<string name="volume_buttons">Кнопки громкости</string> <string name="volume_buttons">Кнопки громкости</string>
<string name="_continue">Продолжить</string> <string name="_continue">Продолжить</string>
<string name="warning">Предупреждение</string> <string name="warning">Предупреждение</string>
<string name="network_consumption_warning">Это может привести к большому расходу трафика</string> <string name="network_consumption_warning">Это может привести к большому расходу трафика</string>
<string name="dont_ask_again">Больше не спрашивать</string> <string name="dont_ask_again">Больше не спрашивать</string>
<string name="cancelling_">Отмена…</string> <string name="cancelling_">Отмена…</string>
<string name="error">Ошибка</string> <string name="error">Ошибка</string>
<string name="clear_thumbs_cache">Очистить кэш миниатюр</string> <string name="clear_thumbs_cache">Очистить кэш миниатюр</string>
<string name="clear_search_history">Очистить историю поиска</string> <string name="clear_search_history">Очистить историю поиска</string>
<string name="search_history_cleared">Очищено</string> <string name="search_history_cleared">Очищено</string>
<string name="gestures_only">Только жесты</string> <string name="gestures_only">Только жесты</string>
<string name="internal_storage">Внутренний накопитель</string> <string name="internal_storage">Внутренний накопитель</string>
<string name="external_storage">Внешний накопитель</string> <string name="external_storage">Внешний накопитель</string>
<string name="domain">Домен</string> <string name="domain">Домен</string>
<string name="application_update">Проверять наличие новых версий приложения</string> <string name="application_update">Проверять наличие новых версий приложения</string>
<string name="app_update_available">Доступна новая версия приложения</string> <string name="app_update_available">Доступна новая версия приложения</string>
<string name="show_notification_app_update">Показ уведомления, если доступна новая версия</string> <string name="show_notification_app_update">Показ уведомления, если доступна новая версия</string>
<string name="open_in_browser">Открыть в веб-браузере</string> <string name="open_in_browser">Открыть в веб-браузере</string>
<string name="large_manga_save_confirm">В этой манге %s. Сохранить их все\?</string> <string name="large_manga_save_confirm">В этой манге %s. Сохранить их все\?</string>
<string name="save_manga">Сохранить</string> <string name="save_manga">Сохранить</string>
<string name="notifications">Уведомления</string> <string name="notifications">Уведомления</string>
<string name="enabled_d_of_d">Включено %1$d из %2$d</string> <string name="enabled_d_of_d">Включено %1$d из %2$d</string>
<string name="new_chapters">Новые главы</string> <string name="new_chapters">Новые главы</string>
<string name="download">Загрузить</string> <string name="download">Загрузить</string>
<string name="read_from_start">Читать с начала</string> <string name="read_from_start">Читать с начала</string>
<string name="restart">Перезапустить</string> <string name="restart">Перезапустить</string>
<string name="notifications_settings">Настройки уведомлений</string> <string name="notifications_settings">Настройки уведомлений</string>
<string name="notification_sound">Звук уведомления</string> <string name="notification_sound">Звук уведомления</string>
<string name="light_indicator">Светодиодная индикация</string> <string name="light_indicator">Светодиодная индикация</string>
<string name="vibration">Вибросигнал</string> <string name="vibration">Вибросигнал</string>
<string name="favourites_categories">Категории избранного</string> <string name="favourites_categories">Категории избранного</string>
<string name="categories_">Категории…</string> <string name="categories_">Категории…</string>
<string name="rename">Переименовать</string> <string name="rename">Переименовать</string>
<string name="category_delete_confirm">Удалить категорию \"%s\" из избранного\? <string name="category_delete_confirm">Удалить категорию \"%s\" из избранного\?
\nВся манга в ней будет потеряна.</string> \nВся манга в ней будет потеряна.</string>
<string name="remove_category">Удалить</string> <string name="remove_category">Удалить</string>
<string name="text_empty_holder_primary">Как-то здесь пусто…</string> <string name="text_empty_holder_primary">Как-то здесь пусто…</string>
<string name="text_search_holder_secondary">Попробуйте переформулировать запрос.</string> <string name="text_search_holder_secondary">Попробуйте переформулировать запрос.</string>
<string name="text_categories_holder">Вы можете использовать категории для организации своих избранных. Нажмите «+», чтобы создать категорию</string> <string name="text_categories_holder">Вы можете использовать категории для организации своих избранных. Нажмите «+», чтобы создать категорию</string>
<string name="text_history_holder_primary">То, что вы прочитаете, будет отображено здесь</string> <string name="text_history_holder_primary">То, что вы прочитаете, будет отображено здесь</string>
<string name="text_history_holder_secondary">Найдите, что почитать, в боковом меню.</string> <string name="text_history_holder_secondary">Найдите, что почитать, в боковом меню.</string>
<string name="text_local_holder_primary">Сохраните что-нибудь</string> <string name="text_local_holder_primary">Сохраните что-нибудь</string>
<string name="text_local_holder_secondary">Сохраните что-нибудь из онлайн-каталога или импортируйте из файла.</string> <string name="text_local_holder_secondary">Сохраните что-нибудь из онлайн-каталога или импортируйте из файла.</string>
<string name="manga_shelf">Полка</string> <string name="manga_shelf">Полка</string>
<string name="recent_manga">Недавнее</string> <string name="recent_manga">Недавнее</string>
<string name="pages_animation">Анимация листания</string> <string name="pages_animation">Анимация листания</string>
<string name="manga_save_location">Папка для загрузок</string> <string name="manga_save_location">Папка для загрузок</string>
<string name="not_available">Недоступно</string> <string name="not_available">Недоступно</string>
<string name="cannot_find_available_storage">Нет доступного хранилища</string> <string name="cannot_find_available_storage">Нет доступного хранилища</string>
<string name="other_storage">Другое хранилище</string> <string name="other_storage">Другое хранилище</string>
<string name="done">Готово</string> <string name="done">Готово</string>
<string name="all_favourites">Всё избранное</string> <string name="all_favourites">Всё избранное</string>
<string name="favourites_category_empty">Категория пуста</string> <string name="favourites_category_empty">Категория пуста</string>
<string name="read_later">Прочитать позже</string> <string name="read_later">Прочитать позже</string>
<string name="updates">Обновления</string> <string name="updates">Обновления</string>
<string name="text_feed_holder">Новые главы из того, что вы читаете, будут показаны здесь</string> <string name="text_feed_holder">Новые главы из того, что вы читаете, будут показаны здесь</string>
<string name="search_results">Результаты поиска</string> <string name="search_results">Результаты поиска</string>
<string name="related">Похожие</string> <string name="related">Похожие</string>
<string name="new_version_s">Новая версия: %s</string> <string name="new_version_s">Новая версия: %s</string>
<string name="size_s">Размер: %s</string> <string name="size_s">Размер: %s</string>
<string name="waiting_for_network">Ожидание подключения…</string> <string name="waiting_for_network">Ожидание подключения…</string>
<string name="clear_updates_feed">Очистить ленту обновлений</string> <string name="clear_updates_feed">Очистить ленту обновлений</string>
<string name="updates_feed_cleared">Очищено</string> <string name="updates_feed_cleared">Очищено</string>
<string name="rotate_screen">Повернуть экран</string> <string name="rotate_screen">Повернуть экран</string>
<string name="update">Обновить</string> <string name="update">Обновить</string>
<string name="feed_will_update_soon">Обновление скоро начнётся</string> <string name="feed_will_update_soon">Обновление скоро начнётся</string>
<string name="track_sources">Следить за обновлениями</string> <string name="track_sources">Следить за обновлениями</string>
<string name="dont_check">Не проверять</string> <string name="dont_check">Не проверять</string>
<string name="enter_password">Введите пароль</string> <string name="enter_password">Введите пароль</string>
<string name="wrong_password">Неверный пароль</string> <string name="wrong_password">Неверный пароль</string>
<string name="protect_application">Защитить приложение</string> <string name="protect_application">Защитить приложение</string>
<string name="protect_application_summary">Запрашивать пароль при запуске Kotatsu</string> <string name="protect_application_summary">Запрашивать пароль при запуске Kotatsu</string>
<string name="repeat_password">Повторите пароль</string> <string name="repeat_password">Повторите пароль</string>
<string name="passwords_mismatch">Пароли не совпадают</string> <string name="passwords_mismatch">Пароли не совпадают</string>
<string name="about">О программе</string> <string name="about">О программе</string>
<string name="app_version">Версия %s</string> <string name="app_version">Версия %s</string>
<string name="check_for_updates">Проверить обновления</string> <string name="check_for_updates">Проверить обновления</string>
<string name="checking_for_updates">Проверка обновления…</string> <string name="checking_for_updates">Проверка обновления…</string>
<string name="update_check_failed">Не удалось проверить обновления</string> <string name="update_check_failed">Не удалось проверить обновления</string>
<string name="no_update_available">Нет доступных обновлений</string> <string name="no_update_available">Нет доступных обновлений</string>
<string name="right_to_left">Справа налево</string> <string name="right_to_left">Справа налево</string>
<string name="create_category">Создать категорию</string> <string name="create_category">Создать категорию</string>
<string name="scale_mode">Масштабирование</string> <string name="scale_mode">Масштабирование</string>
<string name="zoom_mode_fit_center">Вписать в экран</string> <string name="zoom_mode_fit_center">Вписать в экран</string>
<string name="zoom_mode_fit_height">Подогнать по высоте</string> <string name="zoom_mode_fit_height">Подогнать по высоте</string>
<string name="zoom_mode_fit_width">Подогнать по ширине</string> <string name="zoom_mode_fit_width">Подогнать по ширине</string>
<string name="zoom_mode_keep_start">Исходный размер</string> <string name="zoom_mode_keep_start">Исходный размер</string>
<string name="black_dark_theme">Чёрная</string> <string name="black_dark_theme">Чёрная</string>
<string name="black_dark_theme_summary">Потребляет меньше энергии на экранах AMOLED</string> <string name="black_dark_theme_summary">Потребляет меньше энергии на экранах AMOLED</string>
<string name="backup_restore">Резервное копирование и восстановление</string> <string name="backup_restore">Резервное копирование и восстановление</string>
<string name="create_backup">Создать резервную копию</string> <string name="create_backup">Создать резервную копию</string>
<string name="restore_backup">Восстановить данные</string> <string name="restore_backup">Восстановить данные</string>
<string name="data_restored">Восстановлено</string> <string name="data_restored">Восстановлено</string>
<string name="preparing_">Подготовка…</string> <string name="preparing_">Подготовка…</string>
<string name="file_not_found">Файл не найден</string> <string name="file_not_found">Файл не найден</string>
<string name="data_restored_success">Все данные были восстановлены</string> <string name="data_restored_success">Все данные были восстановлены</string>
<string name="data_restored_with_errors">Данные были восстановлены, но возникли некоторые ошибки</string> <string name="data_restored_with_errors">Данные были восстановлены, но возникли некоторые ошибки</string>
<string name="backup_information">Вы можете создать резервную копию избранного и истории и потом восстановить их</string> <string name="backup_information">Вы можете создать резервную копию избранного и истории и потом восстановить их</string>
<string name="just_now">Только что</string> <string name="just_now">Только что</string>
<string name="yesterday">Вчера</string> <string name="yesterday">Вчера</string>
<string name="long_ago">Давно</string> <string name="long_ago">Давно</string>
<string name="group">Группировать</string> <string name="group">Группировать</string>
<string name="today">Сегодня</string> <string name="today">Сегодня</string>
<string name="tap_to_try_again">Попробовать ещё раз</string> <string name="tap_to_try_again">Попробовать ещё раз</string>
<string name="reader_mode_hint">Выбранный режим будет сохранён для текущей манги</string> <string name="reader_mode_hint">Выбранный режим будет сохранён для текущей манги</string>
<string name="silent">Без звука</string> <string name="silent">Без звука</string>
<string name="captcha_required">Необходимо пройти CAPTCHA</string> <string name="captcha_required">Необходимо пройти CAPTCHA</string>
<string name="captcha_solve">Пройти</string> <string name="captcha_solve">Пройти</string>
<string name="clear_cookies">Очистить куки</string> <string name="clear_cookies">Очистить куки</string>
<string name="cookies_cleared">Все файлы cookie были удалены</string> <string name="cookies_cleared">Все файлы cookie были удалены</string>
<string name="chapters_checking_progress">Проверка новых глав: %1$d из %2$d</string> <string name="chapters_checking_progress">Проверка новых глав: %1$d из %2$d</string>
<string name="clear_feed">Очистить ленту</string> <string name="clear_feed">Очистить ленту</string>
<string name="text_clear_updates_feed_prompt">Удалить всю историю обновлений навсегда\?</string> <string name="text_clear_updates_feed_prompt">Удалить всю историю обновлений навсегда\?</string>
<string name="check_for_new_chapters">Проверка новых глав</string> <string name="check_for_new_chapters">Проверка новых глав</string>
<string name="reverse">В обратном порядке</string> <string name="reverse">В обратном порядке</string>
<string name="sign_in">Войти</string> <string name="sign_in">Войти</string>
<string name="auth_required">Авторизуйтесь, чтобы просмотреть этот контент</string> <string name="auth_required">Авторизуйтесь, чтобы просмотреть этот контент</string>
<string name="default_s">По умолчанию: %s</string> <string name="default_s">По умолчанию: %s</string>
<string name="_and_x_more">…и ещё %1$d</string> <string name="_and_x_more">…и ещё %1$d</string>
<string name="next">Далее</string> <string name="next">Далее</string>
<string name="protect_application_subtitle">Введите пароль для запуска приложения</string> <string name="protect_application_subtitle">Введите пароль для запуска приложения</string>
<string name="confirm">Подтвердить</string> <string name="confirm">Подтвердить</string>
<string name="password_length_hint">Пароль должен состоять из 4 символов или более</string> <string name="password_length_hint">Пароль должен состоять из 4 символов или более</string>
<string name="search_only_on_s">Поиск только по %s</string> <string name="search_only_on_s">Поиск только по %s</string>
<string name="other">Другие</string> <string name="other">Другие</string>
<string name="welcome">Добро пожаловать</string> <string name="welcome">Добро пожаловать</string>
<string name="text_clear_search_history_prompt">Удалить все последние поисковые запросы навсегда\?</string> <string name="text_clear_search_history_prompt">Удалить все последние поисковые запросы навсегда\?</string>
<string name="backup_saved">Резервная копия сохранена</string> <string name="backup_saved">Резервная копия сохранена</string>
<string name="tracker_warning">Некоторые устройства имеют различное поведение системы, что может привести к нарушению фоновых задач.</string> <string name="tracker_warning">Некоторые устройства имеют различное поведение системы, что может привести к нарушению фоновых задач.</string>
<string name="read_more">Подробнее</string> <string name="read_more">Подробнее</string>
<string name="queued">В очереди</string> <string name="queued">В очереди</string>
<string name="text_downloads_holder">Нет активных загрузок</string> <string name="text_downloads_holder">Нет активных загрузок</string>
<string name="chapter_is_missing">Глава отсутствует</string> <string name="chapter_is_missing">Глава отсутствует</string>
<string name="chapter_is_missing_text">Скачайте или прочитайте эту недостающую главу онлайн.</string> <string name="chapter_is_missing_text">Скачайте или прочитайте эту недостающую главу онлайн.</string>
<string name="about_app_translation_summary">Помочь с переводом приложения</string> <string name="about_app_translation_summary">Помочь с переводом приложения</string>
<string name="about_app_translation">Перевод</string> <string name="about_app_translation">Перевод</string>
<string name="about_feedback_4pda">Тема на 4PDA</string> <string name="about_feedback_4pda">Тема на 4PDA</string>
<string name="about_feedback">Обратная связь</string> <string name="about_feedback">Обратная связь</string>
<string name="auth_complete">Авторизация выполнена</string> <string name="auth_complete">Авторизация выполнена</string>
<string name="auth_not_supported_by">Вход в %s не поддерживается</string> <string name="auth_not_supported_by">Вход в %s не поддерживается</string>
<string name="text_clear_cookies_prompt">Вы выйдете из всех источников</string> <string name="text_clear_cookies_prompt">Вы выйдете из всех источников</string>
<string name="genres">Жанры</string> <string name="genres">Жанры</string>
<string name="state_finished">Завершено</string> <string name="state_finished">Завершено</string>
<string name="state_ongoing">Онгоинг</string> <string name="state_ongoing">Онгоинг</string>
<string name="date_format">Формат даты</string> <string name="date_format">Формат даты</string>
<string name="system_default">По умолчанию</string> <string name="system_default">По умолчанию</string>
<string name="exclude_nsfw_from_history">Исключить NSFW мангу из истории</string> <string name="exclude_nsfw_from_history">Исключить NSFW мангу из истории</string>
<string name="error_empty_name">Вы должны ввести имя</string> <string name="error_empty_name">Вы должны ввести имя</string>
<string name="show_pages_numbers">Показывать номера страницы</string> <string name="show_pages_numbers">Показывать номера страницы</string>
<string name="enabled_sources">Включенные источники</string> <string name="enabled_sources">Включенные источники</string>
<string name="available_sources">Доступные источники</string> <string name="available_sources">Доступные источники</string>
<string name="dynamic_theme">Динамическая тема</string> <string name="dynamic_theme">Динамическая тема</string>
<string name="dynamic_theme_summary">Применяет тему приложения, основанную на цветовой палитре обоев на устройстве</string> <string name="dynamic_theme_summary">Применяет тему приложения, основанную на цветовой палитре обоев на устройстве</string>
<string name="screenshots_policy">Политика скриншотов</string> <string name="screenshots_policy">Политика скриншотов</string>
<string name="screenshots_allow">Разрешить</string> <string name="screenshots_allow">Разрешить</string>
<string name="screenshots_block_nsfw">Запретить для NSFW</string> <string name="screenshots_block_nsfw">Запретить для NSFW</string>
<string name="screenshots_block_all">Всегда блокировать</string> <string name="screenshots_block_all">Всегда блокировать</string>
<string name="suggestions">Рекомендации</string> <string name="suggestions">Рекомендации</string>
<string name="suggestions_enable">Включить рекомендации</string> <string name="suggestions_enable">Включить рекомендации</string>
<string name="suggestions_summary">Предлагать мангу на основе Ваших предпочтений</string> <string name="suggestions_summary">Предлагать мангу на основе Ваших предпочтений</string>
<string name="suggestions_info">Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы</string> <string name="suggestions_info">Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы</string>
<string name="text_suggestion_holder">Начните читать мангу, чтобы получать персональные предложения</string> <string name="text_suggestion_holder">Начните читать мангу, чтобы получать персональные предложения</string>
<string name="exclude_nsfw_from_suggestions">Не предлагать NSFW мангу</string> <string name="exclude_nsfw_from_suggestions">Не предлагать NSFW мангу</string>
<string name="enabled">Включено</string> <string name="enabled">Включено</string>
<string name="disabled">Выключено</string> <string name="disabled">Выключено</string>
<string name="filter_load_error">Не удалось загрузить список жанров</string> <string name="filter_load_error">Не удалось загрузить список жанров</string>
<string name="computing_">Вычисление…</string> <string name="computing_">Вычисление…</string>
<string name="report_github">Создать проблему на GitHub</string> <string name="report_github">Создать проблему на GitHub</string>
<string name="importing_progress">Импорт манги: %1$d из %2$d</string> <string name="importing_progress">Импорт манги: %1$d из %2$d</string>
<string name="reset_filter">Сбросить фильтр</string> <string name="reset_filter">Сбросить фильтр</string>
<string name="find_genre">Поиск по жанрам</string> <string name="find_genre">Поиск по жанрам</string>
<string name="onboard_text">Выберите языки, на которых Вы хоите читать мангу. Это можно будет изменить позже в настройках.</string> <string name="onboard_text">Выберите языки, на которых Вы хоите читать мангу. Это можно будет изменить позже в настройках.</string>
<string name="never">Никогда</string> <string name="never">Никогда</string>
<string name="only_using_wifi">Только по Wi-Fi</string> <string name="only_using_wifi">Только по Wi-Fi</string>
<string name="always">Всегда</string> <string name="always">Всегда</string>
<string name="preload_pages">Предварительная загрузка страниц</string> <string name="preload_pages">Предварительная загрузка страниц</string>
<string name="logged_in_as">Вы авторизованы как %s</string> <string name="logged_in_as">Вы авторизованы как %s</string>
<string name="nsfw">18+</string> <string name="nsfw">18+</string>
<string name="various_languages">Разные языки</string> <string name="various_languages">Разные языки</string>
<string name="search_chapters">Найти главу</string> <string name="search_chapters">Найти главу</string>
<string name="chapters_empty">В этой манге нет глав</string> <string name="chapters_empty">В этой манге нет глав</string>
<string name="appearance">Оформление</string> <string name="appearance">Оформление</string>
<string name="content">Контент</string> <string name="content">Контент</string>
<string name="suggestions_updating">Обновление рекомендаций</string> <string name="suggestions_updating">Обновление рекомендаций</string>
<string name="suggestions_excluded_genres">Исключить жанры</string> <string name="suggestions_excluded_genres">Исключить жанры</string>
<string name="suggestions_excluded_genres_summary">Укажите жанры, которые Вы не хотите видеть в рекомендациях</string> <string name="suggestions_excluded_genres_summary">Укажите жанры, которые Вы не хотите видеть в рекомендациях</string>
<string name="text_delete_local_manga_batch">Удалить выбранную мангу с накопителя?</string> <string name="text_delete_local_manga_batch">Удалить выбранную мангу с накопителя?</string>
<string name="removal_completed">Удаление завершено</string> <string name="removal_completed">Удаление завершено</string>
<string name="batch_manga_save_confirm">Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе</string> <string name="batch_manga_save_confirm">Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе</string>
<string name="parallel_downloads">Загружать параллельно</string> <string name="parallel_downloads">Загружать параллельно</string>
<string name="download_slowdown">Замедление загрузки</string> <string name="download_slowdown">Замедление загрузки</string>
<string name="download_slowdown_summary">Помогает избежать блокировки IP-адреса</string> <string name="download_slowdown_summary">Помогает избежать блокировки IP-адреса</string>
<string name="local_manga_processing">Обработка сохранённой манги</string> <string name="local_manga_processing">Обработка сохранённой манги</string>
<string name="chapters_will_removed_background">Главы будут удалены в фоновом режиме. Это может занять какое-то время</string> <string name="chapters_will_removed_background">Главы будут удалены в фоновом режиме. Это может занять какое-то время</string>
<string name="hide">Скрыть</string> <string name="hide">Скрыть</string>
<string name="new_sources_text">Доступны новые источники манги</string> <string name="new_sources_text">Доступны новые источники манги</string>
<string name="check_new_chapters_title">Проверять новые главы и уведомлять о них</string> <string name="check_new_chapters_title">Проверять новые главы и уведомлять о них</string>
<string name="show_notification_new_chapters_on">Вы будете получать уведомления об обновлении манги, которую Вы читаете</string> <string name="show_notification_new_chapters_on">Вы будете получать уведомления об обновлении манги, которую Вы читаете</string>
<string name="show_notification_new_chapters_off">Вы не будете получать уведомления, но новые главы будут отображаться в списке</string> <string name="show_notification_new_chapters_off">Вы не будете получать уведомления, но новые главы будут отображаться в списке</string>
<string name="notifications_enable">Включить уведомления</string> <string name="notifications_enable">Включить уведомления</string>
<string name="name">Название</string> <string name="name">Название</string>
<string name="edit">Изменить</string> <string name="edit">Изменить</string>
<string name="edit_category">Изменить категорию</string> <string name="edit_category">Изменить категорию</string>
<string name="tracking">Отслеживание</string> <string name="tracking">Отслеживание</string>
<string name="empty_favourite_categories">Нет категорий избранного</string> <string name="empty_favourite_categories">Нет категорий избранного</string>
<string name="bookmark_add">Добавить закладку</string> <string name="bookmark_add">Добавить закладку</string>
<string name="bookmark_remove">Удалить закладку</string> <string name="bookmark_remove">Удалить закладку</string>
<string name="bookmarks">Закладки</string> <string name="bookmarks">Закладки</string>
<string name="bookmark_removed">Закладка удалена</string> <string name="bookmark_removed">Закладка удалена</string>
<string name="bookmark_added">Закладка добавлена</string> <string name="bookmark_added">Закладка добавлена</string>
<string name="undo">Отменить</string> <string name="undo">Отменить</string>
<string name="removed_from_history">Удалено из истории</string> <string name="removed_from_history">Удалено из истории</string>
<string name="dns_over_https">DNS поверх HTTPS</string> <string name="dns_over_https">DNS поверх HTTPS</string>
<string name="default_mode">Режим по умолчанию</string> <string name="default_mode">Режим по умолчанию</string>
<string name="detect_reader_mode">Автоопределение режима чтения</string> <string name="detect_reader_mode">Автоопределение режима чтения</string>
<string name="detect_reader_mode_summary">Автоматически определяет, является ли манга веб-комиксом</string> <string name="detect_reader_mode_summary">Автоматически определяет, является ли манга веб-комиксом</string>
<string name="disable_battery_optimization">Отключить оптимизацию батареи</string> <string name="disable_battery_optimization">Отключить оптимизацию батареи</string>
<string name="disable_battery_optimization_summary">Помогает с фоновой проверкой обновлений</string> <string name="disable_battery_optimization_summary">Помогает с фоновой проверкой обновлений</string>
<string name="crash_text">Что-то пошло не так. Пожалуйста, отправьте отчёт разработчикам, чтобы помочь всё исправить.</string> <string name="crash_text">Что-то пошло не так. Пожалуйста, отправьте отчёт разработчикам, чтобы помочь всё исправить.</string>
<string name="send">Отправить</string> <string name="send">Отправить</string>
<string name="disable_all">Отключить все</string> <string name="disable_all">Отключить все</string>
<string name="use_fingerprint">Использовать отпечаток пальца, если доступно</string> <string name="use_fingerprint">Использовать отпечаток пальца, если доступно</string>
<string name="appwidget_shelf_description">Манга из Вашего избранного</string> <string name="appwidget_shelf_description">Манга из Вашего избранного</string>
<string name="appwidget_recent_description">Манга, которую Вы недавно читали</string> <string name="appwidget_recent_description">Манга, которую Вы недавно читали</string>
<string name="status_reading">Читаю</string> <string name="status_reading">Читаю</string>
<string name="status_planned">Запланировано</string> <string name="status_planned">Запланировано</string>
<string name="status_on_hold">Отложено</string> <string name="status_on_hold">Отложено</string>
<string name="status_dropped">Заброшено</string> <string name="status_dropped">Заброшено</string>
<string name="status_completed">Завершено</string> <string name="status_completed">Завершено</string>
<string name="show_reading_indicators_summary">Показать процент прочитанного в истории и избранном</string> <string name="show_reading_indicators_summary">Показать процент прочитанного в истории и избранном</string>
<string name="exclude_nsfw_from_history_summary">Манга, помеченная как NSFW, никогда не будет добавлена в историю и ваш прогресс чтения не будет сохранен</string> <string name="exclude_nsfw_from_history_summary">Манга, помеченная как NSFW, никогда не будет добавлена в историю и ваш прогресс чтения не будет сохранен</string>
<string name="percent_string_pattern">%1$s%%</string> <string name="percent_string_pattern">%1$s%%</string>
<string name="report">Отчёт</string> <string name="report">Отчёт</string>
<string name="logout">Выйти</string> <string name="logout">Выйти</string>
<string name="status_re_reading">Перечитываю</string> <string name="status_re_reading">Перечитываю</string>
<string name="show_reading_indicators">Показать индикаторы прогресса чтения</string> <string name="show_reading_indicators">Показать индикаторы прогресса чтения</string>
<string name="data_deletion">Удаление данных</string> <string name="data_deletion">Удаление данных</string>
<string name="clear_cookies_summary">Может помочь в случае каких-либо проблем. Все авторизации будут аннулированы</string> <string name="clear_cookies_summary">Может помочь в случае каких-либо проблем. Все авторизации будут аннулированы</string>
<string name="show_all">Показать все</string> <string name="show_all">Показать все</string>
<string name="invalid_domain_message">Неверное доменное имя</string> <string name="invalid_domain_message">Неверное доменное имя</string>
<string name="text_shelf_holder_secondary">Найдите, что почитать во разделе «Обзор»</string> <string name="text_shelf_holder_secondary">Найдите, что почитать во разделе «Обзор»</string>
<string name="back">Назад</string> <string name="back">Назад</string>
<string name="select_range">Выбрать диапазон</string> <string name="select_range">Выбрать диапазон</string>
<string name="history_cleared">История очищена</string> <string name="history_cleared">История очищена</string>
<string name="manage">Настроить</string> <string name="manage">Настроить</string>
<string name="no_bookmarks_yet">Закладок пока нет</string> <string name="no_bookmarks_yet">Закладок пока нет</string>
<string name="bookmarks_removed">Закладки удалены</string> <string name="bookmarks_removed">Закладки удалены</string>
<string name="no_manga_sources">Нет источников манги</string> <string name="no_manga_sources">Нет источников манги</string>
<string name="no_manga_sources_text">Включите источники манги для чтения онлайн</string> <string name="no_manga_sources_text">Включите источники манги для чтения онлайн</string>
<string name="random">Рандом</string> <string name="random">Случайная</string>
<string name="reorder">Упорядочить</string> <string name="reorder">Упорядочить</string>
<string name="empty">Пусто</string> <string name="empty">Пусто</string>
<string name="explore">Обзор</string> <string name="explore">Обзор</string>
<string name="tools">Инструменты</string> <string name="tools">Инструменты</string>
<string name="confirm_exit">Нажмите Назад ещё раз, чтобы выйти</string> <string name="confirm_exit">Нажмите Назад ещё раз, чтобы выйти</string>
<string name="exit_confirmation_summary">Нажмите Назад 2 раза для выхода из приложения</string> <string name="exit_confirmation_summary">Нажмите Назад 2 раза для выхода из приложения</string>
<string name="other_cache">Другой кэш</string> <string name="other_cache">Другой кэш</string>
<string name="enter_email_text">Введите электронную почту, чтобы продолжить</string> <string name="enter_email_text">Введите электронную почту, чтобы продолжить</string>
<string name="options">Опции</string> <string name="options">Опции</string>
<string name="not_found_404">Контент не найден или был удален</string> <string name="not_found_404">Контент не найден или был удален</string>
<string name="off_short">Отключено</string> <string name="off_short">Выкл.</string>
<string name="seconds_pattern">%s с</string> <string name="seconds_pattern">%s с</string>
<string name="reader_info_pattern">Гл. %1$d/%2$d Стр. %3$d/%4$d</string> <string name="reader_info_pattern">Гл. %1$d/%2$d Стр. %3$d/%4$d</string>
<string name="reader_info_bar">Показывать информационную панель в режиме чтения</string> <string name="reader_info_bar">Показывать информационную панель в режиме чтения</string>
<string name="comics_archive">Архив комиксов</string> <string name="comics_archive">Архив комиксов</string>
<string name="import_completed_hint">Вы можете удалить исходный файл из хранилища, чтобы сэкономить место на нём</string> <string name="import_completed_hint">Вы можете удалить исходный файл из хранилища, чтобы сэкономить место на нём</string>
<string name="incognito_mode">Режим инкогнито</string> <string name="incognito_mode">Режим инкогнито</string>
<string name="sync">Синхронизация</string> <string name="sync">Синхронизация</string>
<string name="sync_title">Синхронизируйте ваши данные</string> <string name="sync_title">Синхронизируйте ваши данные</string>
<string name="email_enter_hint">Введите электронную почту, чтобы продолжить</string> <string name="email_enter_hint">Введите электронную почту, чтобы продолжить</string>
<string name="text_shelf_holder_primary">Ваша манга будет показана здесь</string> <string name="text_shelf_holder_primary">Ваша манга будет показана здесь</string>
<string name="account_already_exists">Аккаунт уже существует</string> <string name="account_already_exists">Аккаунт уже существует</string>
<string name="no_bookmarks_summary">Вы можете создавать закладки во время чтения манги</string> <string name="no_bookmarks_summary">Вы можете создавать закладки во время чтения манги</string>
<string name="exit_confirmation">Подтверждение выхода</string> <string name="exit_confirmation">Подтверждение выхода</string>
<string name="canceled">Отменено</string> <string name="canceled">Отменено</string>
<string name="clear_all_history">Очистить всю историю</string> <string name="clear_all_history">Очистить всю историю</string>
<string name="last_2_hours">Последние 2 часа</string> <string name="last_2_hours">Последние 2 часа</string>
<string name="categories_delete_confirm">Вы уверены, что хотите удалить выбранные категории избранного\? <string name="categories_delete_confirm">Вы уверены, что хотите удалить выбранные категории избранного\?
\nВся манга в них будет потеряна и это не может быть отменено.</string> \nВся манга в них будет потеряна и это не может быть отменено.</string>
<string name="pages_cache">Кэш страниц</string> <string name="pages_cache">Кэш страниц</string>
<string name="changelog">Список изменений</string> <string name="changelog">Список изменений</string>
<string name="memory_usage_pattern">%s - %s</string> <string name="memory_usage_pattern">%s - %s</string>
<string name="storage_usage">Использование хранилища</string> <string name="storage_usage">Использование хранилища</string>
<string name="available">Доступно</string> <string name="available">Доступно</string>
<string name="saved_manga">Сохранённая манга</string> <string name="saved_manga">Сохранённая манга</string>
<string name="removed_from_favourites">Удалено из избранного</string> <string name="removed_from_favourites">Удалено из избранного</string>
<string name="removed_from_s">Удалено из \"%s\"</string> <string name="removed_from_s">Удалено из \"%s\"</string>
<string name="app_update_available_s">Доступно обновление приложения: %s</string> <string name="app_update_available_s">Доступно обновление приложения: %s</string>
<string name="no_chapters">Нет глав</string> <string name="no_chapters">Нет глав</string>
<string name="automatic_scroll">Автоматическое листание</string> <string name="automatic_scroll">Автоматическое листание</string>
<string name="folder_with_images">Папка с изображениями</string> <string name="folder_with_images">Папка с изображениями</string>
<string name="importing_manga">Импорт манги</string> <string name="importing_manga">Импорт манги</string>
<string name="import_completed">Импорт завершён</string> <string name="import_completed">Импорт завершён</string>
<string name="import_will_start_soon">Импорт скоро начнётся</string> <string name="import_will_start_soon">Импорт скоро начнётся</string>
</resources> <string name="feed">Лента</string>
<string name="downloading_manga">Загрузка манги</string>
</resources>

@ -320,4 +320,57 @@
<string name="show_all">Показати всі</string> <string name="show_all">Показати всі</string>
<string name="select_range">Виберіть діапазон</string> <string name="select_range">Виберіть діапазон</string>
<string name="not_found_404">Вміст не знайдено або видалено</string> <string name="not_found_404">Вміст не знайдено або видалено</string>
<string name="text_shelf_holder_primary">Ваша манга буде відображатися тут</string>
<string name="text_shelf_holder_secondary">Знайдіть, що почитати у розділі «Огляд»</string>
<string name="back">Назад</string>
<string name="email_enter_hint">Введіть свою електронну пошту, щоб продовжити</string>
<string name="history_cleared">Історія очищена</string>
<string name="manage">Керувати</string>
<string name="no_bookmarks_yet">Закладок ще немає</string>
<string name="no_bookmarks_summary">Ви можете створювати закладки під час читання манґи</string>
<string name="bookmarks_removed">Закладки видалено</string>
<string name="no_manga_sources">Немає джерел манґи</string>
<string name="no_manga_sources_text">Увімкніть джерела манґи, щоб читати онлайн</string>
<string name="random">Випадкова</string>
<string name="categories_delete_confirm">Ви впевнені, що бажаєте видалити вибрані улюблені категорії\?
\nУсю манґу в них буде втрачено, і це неможливо скасувати.</string>
<string name="reorder">Впорядкувати</string>
<string name="empty">Порожньо</string>
<string name="changelog">Журнал змін</string>
<string name="explore">Огляд</string>
<string name="saved_manga">Збережена манґа</string>
<string name="pages_cache">Кеш сторінок</string>
<string name="storage_usage">Використання сховища</string>
<string name="available">Доступні</string>
<string name="removed_from_s">Видалено з \"%s\"</string>
<string name="options">Параметри</string>
<string name="downloading_manga">Завантаження манґи</string>
<string name="incognito_mode">Режим інкогніто</string>
<string name="app_update_available_s">Доступне оновлення програми: %s</string>
<string name="no_chapters">Немає розділів</string>
<string name="automatic_scroll">Автоматична прокрутка</string>
<string name="off_short">Викл.</string>
<string name="reader_info_bar">Показувати інформаційну панель у режимі читання</string>
<string name="comics_archive">Архів коміксів</string>
<string name="folder_with_images">Папка із зображеннями</string>
<string name="importing_manga">Імпорт манґи</string>
<string name="import_completed">Імпорт завершено</string>
<string name="import_completed_hint">Ви можете видалити оригінальний файл зі сховища, щоб заощадити місце</string>
<string name="import_will_start_soon">Імпорт почнеться незабаром</string>
<string name="feed">Стрічка</string>
<string name="reader_info_pattern">Розд. %1$d/%2$d Стор. %3$d/%4$d</string>
<string name="seconds_pattern">%s с</string>
<string name="account_already_exists">Обліковий запис уже існує</string>
<string name="sync_title">Синхронізуйте ваші дані</string>
<string name="canceled">Скасовано</string>
<string name="confirm_exit">Натисніть Назад ще раз, щоб вийти</string>
<string name="sync">Синхронізація</string>
<string name="clear_all_history">Очистити всю історію</string>
<string name="last_2_hours">Останні 2 години</string>
<string name="exit_confirmation_summary">Двічі натисніть Назад, щоб вийти з програми</string>
<string name="exit_confirmation">Підтвердження виходу</string>
<string name="enter_email_text">Введіть електронну пошту, щоб продовжити</string>
<string name="removed_from_favourites">Видалено з уподобань</string>
<string name="other_cache">Інший кеш</string>
<string name="memory_usage_pattern">%s - %s</string>
</resources> </resources>

@ -6,7 +6,7 @@
<string name="url_twitter" translatable="false">https://twitter.com/kotatsuapp</string> <string name="url_twitter" translatable="false">https://twitter.com/kotatsuapp</string>
<string name="url_reddit" translatable="false">https://reddit.com/user/kotatsuapp</string> <string name="url_reddit" translatable="false">https://reddit.com/user/kotatsuapp</string>
<string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string> <string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
<string name="email_error_report" translatable="false">kotatsu@waifu.club</string> <string name="url_error_report" translatable="false">http://86.57.183.214:8082/report</string>
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string> <string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
<string name="url_sync_server" translatable="false">http://86.57.183.214:8081</string> <string name="url_sync_server" translatable="false">http://86.57.183.214:8081</string>
<string-array name="values_theme" translatable="false"> <string-array name="values_theme" translatable="false">

@ -35,6 +35,8 @@
<dimen name="search_suggestions_manga_spacing">4dp</dimen> <dimen name="search_suggestions_manga_spacing">4dp</dimen>
<dimen name="bottom_sheet_width">0dp</dimen> <dimen name="bottom_sheet_width">0dp</dimen>
<dimen name="bottom_sheet_handle_size_min">16dp</dimen>
<dimen name="bottom_sheet_handle_size_max">24dp</dimen>
<dimen name="dialog_radius">8dp</dimen> <dimen name="dialog_radius">8dp</dimen>
<dimen name="appwidget_corner_radius_inner">8dp</dimen> <dimen name="appwidget_corner_radius_inner">8dp</dimen>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save