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 {
compileSdkVersion 32
buildToolsVersion '32.0.0'
compileSdkVersion 33
buildToolsVersion '33.0.0'
namespace 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
versionCode 490
versionName '4.0-a1'
targetSdkVersion 33
versionCode 493
versionName '4.0-a4'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -29,6 +29,8 @@ android {
// define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
resValue "string", "acra_login", "${localProperty('acra.login')}"
resValue "string", "acra_password", "${localProperty('acra.password')}"
}
buildTypes {
debug {
@ -80,7 +82,7 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:8709c3dd0c') {
implementation('com.github.KotatsuApp:kotatsu-parsers:f112a06ab6') {
exclude group: 'org.json', module: 'json'
}
@ -88,7 +90,7 @@ dependencies {
implementation 'androidx.core:core-ktx:1.8.0'
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-livedata-ktx: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.work:work-runtime-ktx:2.7.1'
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
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-viewbinding:4.3.2'
implementation "com.google.dagger:hilt-android:2.42"
kapt "com.google.dagger:hilt-compiler:2.42"
implementation "com.google.dagger:hilt-android:2.43.2"
kapt "com.google.dagger:hilt-compiler:2.43.2"
implementation 'androidx.hilt:hilt-work: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-svg:2.1.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'io.coil-kt:coil-base:2.2.0'
implementation 'io.coil-kt:coil-svg:2.2.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 'ch.acra:acra-mail:5.9.5'
implementation 'ch.acra:acra-dialog:5.9.5'
implementation 'ch.acra:acra-http:5.9.6'
implementation 'ch.acra:acra-dialog:5.9.6'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
@ -144,6 +147,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.42'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.42'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.43.2'
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 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_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name="org.koitharu.kotatsu.KotatsuApp"

@ -15,9 +15,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.acra.ReportField
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.config.httpSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache
@ -59,16 +60,24 @@ class KotatsuApp : Application(), Configuration.Provider {
super.attachBaseContext(base)
initAcra {
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(
ReportField.PACKAGE_NAME,
ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE,
ReportField.CUSTOM_DATA,
ReportField.CRASH_CONFIGURATION,
ReportField.SHARED_PREFERENCES,
)
dialog {
@ -78,11 +87,6 @@ class KotatsuApp : Application(), Configuration.Provider {
resIcon = R.drawable.ic_alert_outline
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 org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
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(
val manga: Manga?,
@ -13,15 +15,15 @@ class MangaIntent private constructor(
) {
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,
uri = intent?.data
uri = intent?.data,
)
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,
uri = null
uri = null,
)
companion object {

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

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

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

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

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

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

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

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

@ -1,5 +1,6 @@
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.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
@ -10,7 +11,7 @@ import org.koitharu.kotatsu.utils.ext.longHashCode
fun TagEntity.toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource.valueOf(this.source),
source = MangaSource(this.source) ?: MangaSource.DUMMY,
)
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
@ -19,7 +20,7 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState.valueOf(it) },
state = this.state?.let { MangaState(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
@ -27,8 +28,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource.valueOf(this.source),
tags = tags
source = MangaSource(this.source) ?: MangaSource.DUMMY,
tags = tags,
)
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
@ -54,14 +55,18 @@ fun MangaTag.toEntity() = TagEntity(
title = title,
key = key,
source = source.name,
id = "${key}_${source.name}".longHashCode()
id = "${key}_${source.name}".longHashCode(),
)
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
// Other
@Suppress("FunctionName")
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
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 javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
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()) {
return null
return@withContext null
}
return runCatching {
runCatching {
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId }

@ -2,10 +2,12 @@ package org.koitharu.kotatsu.core.os
import android.app.ActivityManager
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ShortcutManager
import android.media.ThumbnailUtils
import android.os.Build
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
@ -22,6 +24,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
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.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
@ -35,13 +38,18 @@ class ShortcutsUpdater @Inject constructor(
private val coil: ImageLoader,
private val historyRepository: HistoryRepository,
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 var shortcutsUpdateJob: Job? = null
override fun onInvalidated(tables: MutableSet<String>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
init {
settings.subscribe(this)
}
override fun onInvalidated(tables: Set<String>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1 || !settings.isDynamicShortcutsEnabled) {
return
}
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 {
return ShortcutManagerCompat.requestPinShortcut(
context,
@ -64,6 +82,15 @@ class ShortcutsUpdater @Inject constructor(
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 {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
@ -74,6 +101,15 @@ class ShortcutsUpdater @Inject constructor(
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 {
val icon = runCatching {
val bmp = coil.execute(

@ -11,6 +11,12 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
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.core.model.ZoomMode
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.putEnumValue
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
class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@ -65,6 +65,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val readerPageSwitch: Set<String>
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
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
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)
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
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
@ -141,6 +137,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
val isDynamicShortcutsEnabled: Boolean
get() = prefs.getBoolean(KEY_SHORTCUTS, true)
var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
@ -324,10 +323,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync"
const val KEY_READER_BAR = "reader_bar"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
// About
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation"
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
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
bottomSheetMediator?.lock()
return true
}
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
(item?.actionView as? SearchView)?.setQuery("", false)
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
(item.actionView as? SearchView)?.setQuery("", false)
viewModel.performChapterSearch(null)
bottomSheetMediator?.unlock()
return true

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

@ -70,6 +70,7 @@ class DetailsFragment :
super.onViewCreated(view, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
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 -> {
startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),

@ -28,4 +28,4 @@ fun MangaChapter.toListItem(
uploadDateMs = uploadDate,
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.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getSerializableCompat
@AndroidEntryPoint
class FavouritesCategoryEditActivity :
@ -70,8 +71,8 @@ class FavouritesCategoryEditActivity :
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
val order = savedInstanceState.getSerializable(KEY_SORT_ORDER)
if (order != null && order is SortOrder) {
val order = savedInstanceState.getSerializableCompat<SortOrder>(KEY_SORT_ORDER)
if (order != null) {
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.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class FavouritesListViewModel @AssistedInject constructor(
@ -53,7 +54,7 @@ class FavouritesListViewModel @AssistedInject constructor(
} else {
repository.observeAll(categoryId)
},
createListModeFlow()
createListModeFlow(),
) { list, mode ->
when {
list.isEmpty() -> listOf(
@ -66,13 +67,13 @@ class FavouritesListViewModel @AssistedInject constructor(
R.string.favourites_category_empty
},
actionStringRes = 0,
)
),
)
else -> list.toUi(mode, this)
}
}.catch {
emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
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.model.*
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.onFirst
@ -60,8 +60,8 @@ class HistoryListViewModel @Inject constructor(
}.onFirst {
loadingCounter.decrement()
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override fun onRefresh() = Unit

@ -56,17 +56,17 @@ class ReadingProgressView @JvmOverloads constructor(
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) {
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) {
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.tracker.domain.TrackingRepository
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
private const val HISTORY_MAX_SEGMENTS = 2
@ -49,8 +49,8 @@ class LibraryViewModel @Inject constructor(
) { history, favourites ->
mapList(history, favourites)
}.catch { e ->
e.toErrorState(canRetry = false)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
emit(listOf(e.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)

@ -6,6 +6,7 @@ import android.view.View
import android.widget.TextView
import androidx.annotation.StyleRes
import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
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)
update()
}
override fun onViewDetachedFromWindow(v: View?) {
override fun onViewDetachedFromWindow(v: View) {
settings.unsubscribe(this)
}
@ -77,7 +78,7 @@ class ItemSizeResolver(resources: Resources, private val settings: AppSettings)
}
if (textAppearanceResId != prevTextAppearance) {
prevTextAppearance = textAppearanceResId
setTextAppearance(textAppearanceResId)
TextViewCompat.setTextAppearance(this, textAppearanceResId)
requestLayout()
}
}

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

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

@ -6,18 +6,21 @@ import android.os.Bundle
import android.view.*
import androidx.appcompat.widget.SearchView
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.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
import org.koitharu.kotatsu.utils.ext.parentFragmentViewModels
class FilterBottomSheet :
BaseBottomSheet<SheetFilterBinding>(),
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener {
DialogInterface.OnKeyListener,
AsyncListDiffer.ListListener<FilterItem> {
private val viewModel by parentFragmentViewModels<RemoteListViewModel>()
@ -33,13 +36,13 @@ class FilterBottomSheet :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = FilterAdapter(viewModel)
val adapter = FilterAdapter(viewModel, this)
binding.recyclerView.adapter = adapter
viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems)
initOptionsMenu()
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true)
return true
}
@ -71,6 +74,12 @@ class FilterBottomSheet :
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() {
binding.headerBar.toolbar.inflateMenu(R.menu.opt_filter)
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)
}
}
when (sortOrder) {
SortOrder.ALPHABETICAL -> list.sortBy { it.title }
SortOrder.RATING -> list.sortBy { it.rating }
}
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

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

@ -1,5 +1,8 @@
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.util.SparseIntArray
import android.view.MenuItem
@ -7,6 +10,7 @@ import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
@ -24,7 +28,6 @@ import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
@ -291,12 +294,11 @@ class MainActivity :
TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
}
requestNotificationsPermission()
when {
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
}
yield()
// TODO get<SyncController>().requestFullSyncAndGc(get())
}
}
@ -347,6 +349,15 @@ class MainActivity :
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?> {
override fun onActivityResult(result: String?) {

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

@ -6,6 +6,7 @@ import android.content.Intent
import android.os.Bundle
import javax.inject.Inject
import javax.inject.Singleton
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
@Singleton
@ -14,7 +15,7 @@ class AppProtectHelper @Inject constructor(private val settings: AppSettings) :
private var isUnlocked = settings.appPassword.isNullOrEmpty()
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)
activity.intent?.let {
sourceIntent.putExtras(it)

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

@ -6,6 +6,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.roundToInt
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
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.parsers.model.MangaChapter
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
@ -33,7 +34,7 @@ class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemC
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val chapters = arguments?.getParcelable<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters
val chapters = arguments?.getParcelableCompat<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters
if (chapters.isNullOrEmpty()) {
dismissAllowingStateLoss()
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.settings.SettingsActivity
import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.IdlingDetector
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.*
@ -51,15 +52,18 @@ class ReaderActivity :
OnPageSelectListener,
ReaderConfigBottomSheet.Callback,
ReaderControlDelegate.OnInteractionListener,
OnApplyWindowInsetsListener {
OnApplyWindowInsetsListener,
IdlingDetector.Callback {
@Inject
lateinit var viewModelFactory: ReaderViewModel.Factory
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
val viewModel by assistedViewModels {
viewModelFactory.create(
intent = MangaIntent(intent),
initialState = intent?.getParcelableExtra(EXTRA_STATE),
initialState = intent?.getParcelableExtraCompat(EXTRA_STATE),
preselectedBranch = intent?.getStringExtra(EXTRA_BRANCH),
)
}
@ -70,6 +74,9 @@ class ReaderActivity :
pageSwitchTimer.delaySec = value
}
override val readerMode: ReaderMode?
get() = readerManager.currentMode
private lateinit var pageSwitchTimer: PageSwitchTimer
private lateinit var touchHelper: GridTouchHelper
private lateinit var controlDelegate: ReaderControlDelegate
@ -84,11 +91,12 @@ class ReaderActivity :
supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this)
pageSwitchTimer = PageSwitchTimer(this, this)
controlDelegate = ReaderControlDelegate(lifecycleScope, settings, this)
controlDelegate = ReaderControlDelegate(settings, this, this)
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
binding.slider.setLabelFormatter(PageLabelFormatter())
ReaderSliderListener(this, viewModel).attachToSlider(binding.slider)
insetsDelegate.interceptingWindowInsetsListener = this
idlingDetector.bindToLifecycle(this)
viewModel.onError.observe(this, this::onError)
viewModel.readerMode.observe(this, this::onInitReader)
@ -111,6 +119,11 @@ class ReaderActivity :
override fun onUserInteraction() {
super.onUserInteraction()
pageSwitchTimer.onUserInteraction()
idlingDetector.onUserInteraction()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
}
private fun onInitReader(mode: ReaderMode) {

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

@ -1,33 +1,39 @@
package org.koitharu.kotatsu.reader.ui
import android.content.SharedPreferences
import android.view.KeyEvent
import android.view.SoundEffectConstants
import android.view.View
import androidx.lifecycle.LifecycleCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
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
class ReaderControlDelegate(
scope: LifecycleCoroutineScope,
settings: AppSettings,
private val settings: AppSettings,
private val listener: OnInteractionListener,
) {
owner: LifecycleOwner,
) : DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener {
private var isTapSwitchEnabled: Boolean = true
private var isVolumeKeysSwitchEnabled: Boolean = false
private var isReaderTapsAdaptive: Boolean = true
init {
settings.observeAsFlow(AppSettings.KEY_READER_SWITCHERS) { readerPageSwitch }
.flowOn(Dispatchers.Default)
.onEach {
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in it
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in it
}.launchIn(scope)
owner.lifecycle.addObserver(this)
settings.subscribe(this)
updateSettings()
}
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) {
@ -41,7 +47,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP)
}
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
listener.switchPageBy(-1)
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT)
}
GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) {
@ -49,7 +55,7 @@ class ReaderControlDelegate(
view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN)
}
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
listener.switchPageBy(1)
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT)
}
}
@ -72,19 +78,25 @@ class ReaderControlDelegate(
KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_RIGHT,
-> {
listener.switchPageBy(1)
true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
true
}
KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_LEFT,
-> {
listener.switchPageBy(-1)
true
}
KeyEvent.KEYCODE_DPAD_LEFT -> {
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
true
}
KeyEvent.KEYCODE_DPAD_CENTER -> {
listener.toggleUiVisibility()
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 {
val readerMode: ReaderMode?
fun switchPageBy(delta: Int)
fun toggleUiVisibility()

@ -8,17 +8,19 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.icu.text.SimpleDateFormat
import android.util.AttributeSet
import android.view.View
import androidx.annotation.AttrRes
import androidx.core.graphics.ColorUtils
import com.google.android.material.R as materialR
import java.text.SimpleDateFormat
import java.util.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
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
class ReaderInfoBarView @JvmOverloads constructor(
@ -29,23 +31,46 @@ class ReaderInfoBarView @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textBounds = Rect()
private val inset = context.resources.resolveDp(2f)
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
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 text: String = ""
private val innerHeight
get() = height - inset - inset - paddingTop - paddingBottom
get() = height - paddingTop - paddingBottom - insetTop
private val innerWidth
get() = width - inset - inset - paddingLeft - paddingRight
get() = width - paddingLeft - paddingRight - insetLeft - insetRight
init {
paint.color = ColorUtils.setAlphaComponent(
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
160,
paint.strokeWidth = context.resources.resolveDp(2f)
val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
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)
val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom
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
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) {
@ -103,6 +128,15 @@ class ReaderInfoBarView @JvmOverloads constructor(
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() {
override fun onReceive(context: Context?, intent: Intent?) {
@ -110,4 +144,13 @@ class ReaderInfoBarView @JvmOverloads constructor(
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.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
private const val KEY_STATE = "state"
@ -18,7 +19,7 @@ abstract class BaseReader<B : ViewBinding> : BaseFragment<B>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
var restoredState = savedInstanceState?.getParcelable<ReaderState?>(KEY_STATE)
var restoredState = savedInstanceState?.getParcelableCompat<ReaderState>(KEY_STATE)
viewModel.content.observe(viewLifecycleOwner) {
onPagesChanged(it.pages, restoredState ?: it.state)

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

@ -69,7 +69,7 @@ open class PageHolder(
override fun onImageShowing(zoom: ZoomMode) {
binding.ssiv.maxScale = 2f * maxOf(
binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
binding.ssiv.height / binding.ssiv.sHeight.toFloat()
binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
)
when (zoom) {
ZoomMode.FIT_CENTER -> {
@ -81,7 +81,7 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat()
binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale,
PointF(0f, binding.ssiv.sHeight / 2f)
PointF(0f, binding.ssiv.sHeight / 2f),
)
}
ZoomMode.FIT_WIDTH -> {
@ -89,14 +89,14 @@ open class PageHolder(
binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat()
binding.ssiv.setScaleAndCenter(
binding.ssiv.minScale,
PointF(binding.ssiv.sWidth / 2f, 0f)
PointF(binding.ssiv.sWidth / 2f, 0f),
)
}
ZoomMode.KEEP_START -> {
binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE)
binding.ssiv.setScaleAndCenter(
binding.ssiv.maxScale,
PointF(0f, 0f)
PointF(0f, 0f),
)
}
}
@ -115,9 +115,9 @@ open class PageHolder(
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.layoutError.isVisible = true
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.ui.ReaderActivity
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.withArgs
@ -52,7 +53,7 @@ class PagesThumbnailsSheet :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pages = arguments?.getParcelable<ParcelableMangaPages>(ARG_PAGES)?.pages
val pages = arguments?.getParcelableCompat<ParcelableMangaPages>(ARG_PAGES)?.pages
if (pages.isNullOrEmpty()) {
dismissAllowingStateLoss()
return

@ -99,7 +99,7 @@ class RemoteListFragment : MangaListFragment() {
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)
return true
}

@ -10,10 +10,13 @@ private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor {
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)
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
if (!sourceRequest.url.pathSegments.contains("oauth")) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) {
@ -21,4 +24,4 @@ class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor
}
return response
}
}
}

@ -40,13 +40,14 @@ class ShikimoriRepository(
suspend fun authorize(code: String?) {
val body = FormBody.Builder()
body.add("grant_type", "authorization_code")
body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID)
body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET)
if (code != null) {
body.add("grant_type", "authorization_code")
body.add("redirect_uri", REDIRECT_URI)
body.add("code", code)
} else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken))
}
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.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.requireParcelable
import org.koitharu.kotatsu.utils.ext.withArgs
@AndroidEntryPoint
@ -47,7 +48,7 @@ class ScrobblingSelectorBottomSheet :
private val viewModel by assistedViewModels {
viewModelFactory.create(
requireNotNull(requireArguments().getParcelable<ParcelableManga>(MangaIntent.KEY_MANGA)).manga,
requireArguments().requireParcelable<ParcelableManga>(MangaIntent.KEY_MANGA).manga,
)
}
@ -84,7 +85,7 @@ class ScrobblingSelectorBottomSheet :
dismiss()
}
viewModel.searchQuery.observe(viewLifecycleOwner) {
binding.headerBar.toolbar.subtitle = it
binding.headerBar.subtitle = it
}
}
@ -102,7 +103,7 @@ class ScrobblingSelectorBottomSheet :
viewModel.loadList(append = true)
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = 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.MangaTag
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
@AndroidEntryPoint
class MangaListActivity :
@ -29,7 +30,7 @@ class MangaListActivity :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityContainerBinding.inflate(layoutInflater))
val tags = intent.getParcelableExtra<ParcelableMangaTags>(EXTRA_TAGS)?.tags
val tags = intent.getParcelableExtraCompat<ParcelableMangaTags>(EXTRA_TAGS)?.tags
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val source = intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: tags?.firstOrNull()?.source
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.base.ui.BasePreferenceFragment
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.local.data.CacheDir
import org.koitharu.kotatsu.local.data.LocalStorageManager
@ -41,8 +42,13 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
@Inject
lateinit var cookieJar: AndroidCookieJar
@Inject
lateinit var shortcutsUpdater: ShortcutsUpdater
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_history)
findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
shortcutsUpdater.isDynamicShortcutsAvailable()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

@ -2,6 +2,8 @@ package org.koitharu.kotatsu.settings
import android.os.Bundle
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
@ -53,7 +55,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(KEY_AUTH)?.run {
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 {
preference.summary = null
withContext(Dispatchers.Default) {
@ -99,7 +101,8 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
viewLifecycleScope.launch {
if (exceptionResolver.resolve(error)) {
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.os.Bundle
import android.view.View
import androidx.core.net.toUri
import androidx.fragment.app.viewModels
import androidx.preference.Preference
import kotlinx.coroutines.launch
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
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.settings.AppUpdateChecker
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
private val viewModel by viewModels<AboutSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
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 {
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 {
return when (preference.key) {
AppSettings.KEY_APP_VERSION -> {
checkForUpdates()
viewModel.checkForUpdates()
true
}
AppSettings.KEY_APP_TRANSLATION -> {
@ -40,24 +47,12 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
}
}
private fun checkForUpdates() {
viewLifecycleScope.launch {
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
setSummary(R.string.checking_for_updates)
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
}
private fun onUpdateAvailable(version: AppVersion?) {
if (version == null) {
Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
return
}
AppUpdateDialog(context ?: return).show(version)
}
private fun openLink(url: String, title: CharSequence?) {
@ -68,7 +63,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
Intent.createChooser(intent, title)
} else {
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)
}
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder.setCancelable(false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setCancelable(false)
.setNegativeButton(android.R.string.cancel, null)
}

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

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

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

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

@ -26,6 +26,7 @@ import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.databinding.FragmentToolsBinding
import org.koitharu.kotatsu.download.ui.DownloadsActivity
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.utils.FileSize
import org.koitharu.kotatsu.utils.ext.getThemeColor
@ -68,6 +69,10 @@ class ToolsFragment :
intent.data = url.toUri()
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.toUi
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
@HiltViewModel
@ -44,8 +44,8 @@ class SuggestionsViewModel @Inject constructor(
}.onFirst {
loadingCounter.decrement()
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(
emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(
viewModelScope.coroutineContext + Dispatchers.Default,
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.tracker.domain.model.TrackingLogItem
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
id = trackLog.id,
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt)
)
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
return TrackingLogItem(
id = trackLog.id,
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")
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")
abstract fun observeNewChapters(): Flow<List<Int>>

@ -5,6 +5,8 @@ import androidx.room.withTransaction
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@ -73,9 +75,15 @@ class TrackingRepository @Inject constructor(
db.tracksDao.delete(mangaId)
}
suspend fun getTrackingLog(offset: Int, limit: Int): List<TrackingLogItem> {
return db.trackLogsDao.findAll(offset, limit).map { x ->
x.toTrackingLogItem()
fun observeTrackingLog(limit: Flow<Int>): Flow<List<TrackingLogItem>> {
return limit.flatMapLatest { limitValue ->
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 manga: Manga,
val chapters: List<String>,
val createdAt: Date
)
val createdAt: Date,
val isNew: Boolean,
)

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

@ -4,44 +4,39 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.EmptyState
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.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
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
private const val PAGE_SIZE = 20
@HiltViewModel
class FeedViewModel @Inject constructor(
private val repository: TrackingRepository,
) : BaseViewModel() {
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null)
private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null
private val limit = MutableStateFlow(PAGE_SIZE)
private val isReady = AtomicBoolean(false)
val onFeedCleared = SingleLiveEvent<Unit>()
val content = combine(
logList.filterNotNull(),
hasNextPage,
) { list, isHasNextPage ->
buildList(list.size + 2) {
val content = repository.observeTrackingLog(limit)
.map { list ->
if (list.isEmpty()) {
add(
listOf(
EmptyState(
icon = R.drawable.ic_empty_feed,
textPrimary = R.string.text_empty_holder_primary,
@ -50,48 +45,26 @@ class FeedViewModel @Inject constructor(
),
)
} else {
list.mapListTo(this)
if (isHasNextPage) {
add(LoadingFooter)
}
isReady.set(true)
list.mapList()
}
}
}.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()
}
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun clearFeed() {
val lastJob = loadingJob
loadingJob = launchLoadingJob(Dispatchers.Default) {
lastJob?.cancelAndJoin()
launchLoadingJob(Dispatchers.Default) {
repository.clearLogs()
logList.value = emptyList()
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
for (item in this) {
val date = timeAgo(item.createdAt)
@ -101,6 +74,7 @@ class FeedViewModel @Inject constructor(
prevDate = date
destination += item.toFeedItem()
}
return destination
}
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.utils.ext.disposeImageRequest
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
fun feedItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>
clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>(
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) },
) {
itemView.setOnClickListener {
clickListener.onItemClick(item.manga, it)
}
bind {
binding.textViewTitle.isBold = item.isNew
binding.textViewSummary.isBold = item.isNew
binding.imageViewCover.newImageRequest(item.imageUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)

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

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

@ -7,7 +7,7 @@ import kotlin.math.roundToInt
class GridTouchHelper(
context: Context,
private val listener: OnGridTouchListener
private val listener: OnGridTouchListener,
) : GestureDetector.SimpleOnGestureListener() {
private val detector = GestureDetector(context, this)
@ -16,7 +16,7 @@ class GridTouchHelper(
private var isDispatching = false
init {
detector.setIsLongpressEnabled(false)
detector.setIsLongpressEnabled(true)
detector.setOnDoubleTapListener(this)
}
@ -46,7 +46,7 @@ class GridTouchHelper(
}
2 -> AREA_RIGHT
else -> return false
}
},
)
return true
}
@ -66,4 +66,4 @@ class GridTouchHelper(
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.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
@ -47,7 +48,18 @@ fun ImageResult.toBitmapOrNull() = when (this) {
}
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 {
@ -63,3 +75,11 @@ fun ImageRequest.Builder.crossfade(context: Context?): ImageRequest.Builder {
val duration = context.resources.getInteger(R.integer.config_defaultAnimTime) * context.animatorDurationScale
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
import android.os.Bundle
import android.os.Parcelable
import androidx.core.view.MenuProvider
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope
import java.io.Serializable
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val b = Bundle(size)
@ -20,18 +23,10 @@ inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val Fragment.viewLifecycleScope
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> {
return lazy(LazyThreadSafetyMode.NONE) {
@Suppress("UNCHECKED_CAST")
requireNotNull(arguments?.getSerializable(name)) {
requireNotNull(arguments?.getSerializableCompat(name)) {
"No argument $name passed into ${javaClass.simpleName}"
} as T
}
@ -50,3 +45,19 @@ fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
fun Fragment.addMenuProvider(provider: MenuProvider) {
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
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.TextView
@ -48,3 +49,15 @@ fun TextView.setTextAndVisible(@StringRes textResId: Int) {
fun TextView.setTextColorAttr(@AttrRes attrResId: Int) {
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.graphics.Color
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
import androidx.annotation.StyleRes
import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat
fun Context.getThemeDrawable(
@AttrRes resId: Int,
@ -43,3 +46,9 @@ fun Context.getThemeColorStateList(
) = obtainStyledAttributes(intArrayOf(resId)).use {
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.graphics.Rect
import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager
@ -167,3 +168,19 @@ val View.parents: Sequence<ViewParent>
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:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="@dimen/margin_normal"
android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"

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

@ -42,9 +42,11 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone"
app:behavior_hideable="false"
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
android:id="@+id/header_chapters"

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

@ -28,9 +28,9 @@
<org.koitharu.kotatsu.reader.ui.ReaderInfoBarView
android:id="@+id/infoBar"
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:paddingHorizontal="4dp"
android:minHeight="12sp"
android:visibility="gone"
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:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="@dimen/margin_normal"
android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"

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

@ -2,7 +2,6 @@
<com.google.android.material.card.MaterialCardView
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"
android:id="@+id/card_update"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
@ -53,7 +52,7 @@
style="@style/Widget.Material3.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginTop="12dp"
android:text="@string/download"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/textSecondary" />
@ -61,4 +60,4 @@
</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_height="wrap_content"
android:layout_weight="1"
android:background="?selectableItemBackgroundBorderless"
android:drawablePadding="4dp"
android:gravity="center"
android:padding="4dp"

@ -6,27 +6,20 @@
android:layout_height="wrap_content"
tools:parentTag="com.google.android.material.appbar.AppBarLayout">
<FrameLayout
android:id="@+id/frame"
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/dragHandle"
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
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar"
tools:navigationIcon="?actionModeCloseDrawable"
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>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.Kotatsu.MainToolbar"
tools:navigationIcon="?actionModeCloseDrawable"
tools:title="@string/options" />
</merge>

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

@ -103,7 +103,7 @@
android:text="@string/reader_mode_hint"
android:textAppearance="?attr/textAppearanceBodySmall" />
<LinearLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
@ -115,23 +115,38 @@
android:paddingEnd="?android:listPreferredItemPaddingEnd">
<TextView
android:layout_width="wrap_content"
android:id="@+id/textView_timer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:ellipsize="end"
android:singleLine="true"
android:text="@string/automatic_scroll"
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
android:id="@+id/slider_timer"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal"
android:contentDescription="@string/automatic_scroll"
android:labelFor="@id/textView_timer"
android:valueFrom="0"
android:valueTo="20"
app:labelBehavior="floating" />
</LinearLayout>
app:labelBehavior="floating"
app:layout_constraintBottom_toBottomOf="parent"
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
android:id="@+id/button_color_filter"

@ -288,4 +288,42 @@
<string name="reverse">Balik</string>
<string name="queued">Diantrikan</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>

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

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

@ -320,4 +320,57 @@
<string name="show_all">Показати всі</string>
<string name="select_range">Виберіть діапазон</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>

@ -6,7 +6,7 @@
<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_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="url_sync_server" translatable="false">http://86.57.183.214:8081</string>
<string-array name="values_theme" translatable="false">

@ -35,6 +35,8 @@
<dimen name="search_suggestions_manga_spacing">4dp</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="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