Compare commits

...

31 Commits

Author SHA1 Message Date
Koitharu
d242acd502 Sort local list by manga name 2022-09-03 15:57:48 +03:00
Koitharu
d37b44d3f6 Update parsers 2022-09-03 15:56:16 +03:00
Koitharu
e4c4d2bbf0 Fix crashes 2022-08-27 09:38:54 +03:00
Koitharu
040d3e4433 Reader control direction depends on mode #214 2022-08-26 12:13:18 +03:00
Koitharu
b4f93fc0a5 Fix showing reader control by long press 2022-08-26 10:09:48 +03:00
Koitharu
c4e7807d18 Update version 2022-08-23 09:23:34 +03:00
Koitharu
8e55a4d824 Fix Shikimori authToken refreshing 2022-08-19 11:46:05 +03:00
Koitharu
c1e9fde6e8 Update parsers and version 2022-08-17 16:51:03 +03:00
Koitharu
32e80c7e95 Fix downloads cancellation #210 2022-08-11 15:49:44 +03:00
Koitharu
c07a3b9d0d Download control buttons in list 2022-08-11 12:55:02 +03:00
Koitharu
893d1a881d Fix download service stopping #210 2022-08-11 12:15:24 +03:00
Koitharu
43ef130052 Group download notification 2022-08-11 11:40:27 +03:00
Koitharu
d5bea0ca53 Fix DownloadService leak 2022-08-11 10:18:44 +03:00
Koitharu
9c740c5cc1 Fix settings title 2022-08-10 15:30:57 +03:00
lowak
cf7535e2ba Translated using Weblate (Swedish)
Currently translated at 100.0% (323 of 323 strings)

Co-authored-by: lowak <lowak@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
2022-08-10 15:14:17 +03:00
Koitharu
87afad29ce Update parsers 2022-08-08 16:11:24 +03:00
Koitharu
436233e735 Fix description text color 2022-08-08 11:49:48 +03:00
Koitharu
6e367ddd74 Failsafe implementation of MangaSource.valueOf 2022-08-08 11:04:35 +03:00
Koitharu
fcdfaf5564 Fix covers size resolving 2022-08-08 10:50:53 +03:00
Koitharu
dff17fd11f Change BaseSavedState to AbsSavedState 2022-08-06 18:37:25 +03:00
Koitharu
85af73df99 Update dependencies 2022-08-04 13:43:06 +03:00
Koitharu
c7a97711c0 Optimize chapters mapping 2022-08-04 11:59:09 +03:00
Koitharu
ffbe05b2ae Fix tracker for multiple branches 2022-08-04 11:32:50 +03:00
Koitharu
14f5d5daa4 Update parsers 2022-08-01 17:14:35 +03:00
Koitharu
f342cd6b56 Fix crash on widgets update 2022-08-01 17:00:00 +03:00
Koitharu
8faacab53a Fix github url 2022-07-30 16:10:03 +03:00
Koitharu
659c327a6d Update parsers and version 2022-07-30 16:05:01 +03:00
Koitharu
bcc2f531c3 Ability to resume download after IOException 2022-07-30 16:02:13 +03:00
Koitharu
020df5c1f7 Fix saving pages from cbz 2022-07-30 14:15:12 +03:00
Koitharu
d6781e1d14 Yet another attempt to make webtoon reader great again 2022-07-29 15:39:04 +03:00
Zakhar Timoshenko
d42cd59880 Fix enabling disabled new sources
Co-authored-by: Koitharu <8948226+nv95@users.noreply.github.com>
2022-07-28 09:49:30 +03:00
81 changed files with 1643 additions and 1041 deletions

View File

@@ -15,5 +15,6 @@ disabled_rules=no-wildcard-imports,no-unused-imports
ij_continuation_indent_size = 4
[{*.kt,*.kts}]
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_allow_trailing_comma = true
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
versionCode 419
versionName '3.4.7'
versionCode 425
versionName '3.4.13'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -79,19 +79,19 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.nv95:kotatsu-parsers:7588617316') {
implementation('com.github.KotatsuApp:kotatsu-parsers:551a1d70ae') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.5.0'
implementation 'androidx.fragment:fragment-ktx:1.5.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0'
implementation 'androidx.lifecycle:lifecycle-service:2.5.0'
implementation 'androidx.lifecycle:lifecycle-process:2.5.0'
implementation 'androidx.activity:activity-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'
implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
@@ -99,13 +99,13 @@ 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-rc01'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0'
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
implementation 'androidx.room:room-runtime:2.4.2'
implementation 'androidx.room:room-ktx:2.4.2'
kapt 'androidx.room:room-compiler:2.4.2'
implementation 'androidx.room:room-runtime:2.4.3'
implementation 'androidx.room:room-ktx:2.4.3'
kapt 'androidx.room:room-compiler:2.4.3'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
@@ -115,12 +115,12 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'io.insert-koin:koin-android:3.2.0'
implementation 'io.coil-kt:coil-base:2.1.0'
implementation 'io.coil-kt:coil-base:2.2.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
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-mail:5.9.6'
implementation 'ch.acra:acra-dialog:5.9.6'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
@@ -137,6 +137,6 @@ dependencies {
androidTestImplementation 'io.insert-koin:koin-test:3.2.0'
androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
androidTestImplementation 'androidx.room:room-testing:2.4.2'
androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
}

View File

@@ -10,4 +10,6 @@
}
-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

View File

@@ -111,6 +111,7 @@
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:stopWithTask="false"
android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service

View File

@@ -73,7 +73,7 @@ class KotatsuApp : Application() {
appWidgetModule,
suggestionsModule,
shikimoriModule,
bookmarksModule,
bookmarksModule
)
}
}
@@ -91,8 +91,7 @@ class KotatsuApp : Application() {
ReportField.PHONE_MODEL,
ReportField.CRASH_CONFIGURATION,
ReportField.STACK_TRACE,
ReportField.CUSTOM_DATA,
ReportField.SHARED_PREFERENCES,
ReportField.SHARED_PREFERENCES
)
dialog {
text = getString(R.string.crash_text)

View File

@@ -10,6 +10,7 @@ import android.widget.Checkable
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.os.ParcelCompat
import androidx.customview.view.AbsSavedState
class CheckableImageView @JvmOverloads constructor(
context: Context,
@@ -73,7 +74,7 @@ class CheckableImageView @JvmOverloads constructor(
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
}
private class SavedState : BaseSavedState {
private class SavedState : AbsSavedState {
val isChecked: Boolean
@@ -81,7 +82,7 @@ class CheckableImageView @JvmOverloads constructor(
isChecked = checked
}
constructor(source: Parcel) : super(source) {
constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
isChecked = ParcelCompat.readBoolean(source)
}
@@ -91,9 +92,10 @@ class CheckableImageView @JvmOverloads constructor(
}
companion object {
@Suppress("unused")
@JvmField
val CREATOR: Creator<SavedState> = object : Creator<SavedState> {
override fun createFromParcel(`in`: Parcel) = SavedState(`in`)
override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
}

View File

@@ -25,7 +25,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = FragmentCloudflareBinding.inflate(inflater, container, false)
@SuppressLint("SetJavaScriptEnabled")
@@ -49,6 +49,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onDestroyView() {
binding.webView.stopLoading()
binding.webView.destroy()
super.onDestroyView()
}
@@ -77,7 +78,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onCheckPassed() {
pendingResult.putBoolean(EXTRA_RESULT, true)
dismiss()
dismissAllowingStateLoss()
}
companion object {

View File

@@ -1,6 +1,34 @@
package org.koitharu.kotatsu.core.model
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<Manga>.ids() = mapToSet { it.id }
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters
if (ch.isNullOrEmpty()) {
return null
}
if (history != null) {
val currentChapter = ch.find { it.id == history.chapterId }
if (currentChapter != null) {
return currentChapter.branch
}
}
val groups = ch.groupBy { it.branch }
for (locale in LocaleListCompat.getAdjustedDefault()) {
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.getDisplayName(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
}
return groups.maxByOrNull { it.value.size }?.key
}

View File

@@ -1,10 +1,18 @@
package org.koitharu.kotatsu.core.model
import java.util.*
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.*
fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
@Suppress("FunctionName")
fun MangaSource(name: String): MangaSource? {
MangaSource.values().forEach {
if (it.name == name) return it
}
return null
}

View File

@@ -5,7 +5,7 @@ import coil.map.Mapper
import coil.request.Options
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaSource
class FaviconMapper : Mapper<Uri, HttpUrl> {
@@ -13,7 +13,7 @@ class FaviconMapper : Mapper<Uri, HttpUrl> {
if (data.scheme != "favicon") {
return null
}
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
val mangaSource = MangaSource(data.schemeSpecificPart) ?: return null
val repo = MangaRepository(mangaSource) as RemoteMangaRepository
return repo.getFaviconUrl().toHttpUrl()
}

View File

@@ -14,9 +14,6 @@ import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
@@ -64,6 +61,9 @@ class AppSettings(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) }
@@ -314,6 +314,7 @@ class AppSettings(context: Context) {
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -105,7 +105,7 @@ class DetailsActivity :
Toast.makeText(
this,
getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT
Toast.LENGTH_SHORT,
).show()
finishAfterTransition()
}
@@ -131,7 +131,7 @@ class DetailsActivity :
onActionClick = {
e.report("DetailsActivity::onError")
dismiss()
}
},
)
}
else -> {
@@ -142,14 +142,14 @@ class DetailsActivity :
override fun onWindowInsetsChanged(insets: Insets) {
binding.snackbar.updatePadding(
bottom = insets.bottom
bottom = insets.bottom,
)
binding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
binding.root.updatePadding(
left = insets.left,
right = insets.right
right = insets.right,
)
}
@@ -159,6 +159,7 @@ class DetailsActivity :
tab.removeBadge()
} else {
val badge = tab.orCreateBadge
badge.maxCharacterCount = 3
badge.number = newChapters
badge.isVisible = true
}
@@ -275,8 +276,8 @@ class DetailsActivity :
ReaderActivity.newIntent(
context = this@DetailsActivity,
manga = remoteManga,
state = ReaderState(chapterId, 0, 0)
)
state = ReaderState(chapterId, 0, 0),
),
)
}
setNeutralButton(R.string.download) { _, _ ->
@@ -350,8 +351,8 @@ class DetailsActivity :
dialogBuilder.setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
)
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
),
).setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, manga)
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui
import android.app.ActivityOptions
import android.os.Bundle
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.view.*
import androidx.appcompat.widget.PopupMenu
@@ -10,18 +9,15 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import androidx.core.view.MenuProvider
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
@@ -33,6 +29,7 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
@@ -49,6 +46,7 @@ import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(),
@@ -82,6 +80,7 @@ class DetailsFragment :
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
addMenuProvider(DetailsMenuProvider())
}
@@ -126,18 +125,6 @@ class DetailsFragment :
else -> textViewState.isVisible = false
}
// Info containers
val chapters = manga.chapters
if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false
} else {
infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString(
R.plurals.chapters,
chapters.size,
chapters.size,
)
}
if (manga.hasRating) {
infoLayout.textViewRating.text = String.format("%.1f", manga.rating * 5)
infoLayout.ratingContainer.isVisible = true
@@ -164,14 +151,27 @@ class DetailsFragment :
infoLayout.textViewNsfw.isVisible = manga.isNsfw
// Buttons
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
// Chips
bindTags(manga)
}
}
private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
val infoLayout = binding.infoLayout
if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false
} else {
infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString(
R.plurals.chapters,
chapters.size,
chapters.size,
)
}
// Buttons
binding.buttonRead.isEnabled = !chapters.isNullOrEmpty()
}
private fun onDescriptionChanged(description: CharSequence?) {
if (description.isNullOrBlank()) {
binding.textViewDescription.setText(R.string.no_description)
@@ -266,7 +266,7 @@ class DetailsFragment :
context = context ?: return,
manga = manga,
branch = viewModel.selectedBranchValue,
)
),
)
}
}
@@ -276,14 +276,14 @@ class DetailsFragment :
context = v.context,
source = manga.source,
query = manga.author ?: return,
)
),
)
}
R.id.imageView_cover -> {
val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
options.toBundle()
options.toBundle(),
)
}
}
@@ -309,8 +309,8 @@ class DetailsFragment :
c.chapter.branch == branch
}?.let { c ->
ReaderState(c.chapter.id, 0, 0)
}
)
},
),
)
true
}
@@ -343,7 +343,7 @@ class DetailsFragment :
icon = 0,
data = tag,
)
}
},
)
}
@@ -355,13 +355,22 @@ class DetailsFragment :
}
val request = ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover)
.size(CoverSizeResolver(binding.imageViewCover))
.data(imageUrl)
.crossfade(true)
.referer(manga.publicUrl)
.lifecycle(viewLifecycleOwner)
lastResult?.drawable?.let {
request.fallback(it)
} ?: request.fallback(R.drawable.ic_placeholder)
.placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable
if (previousDrawable != null) {
request.fallback(previousDrawable)
.placeholder(previousDrawable)
.error(previousDrawable)
} else {
request.fallback(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
}
request.enqueueWith(coil)
}

View File

@@ -1,11 +1,16 @@
package org.koitharu.kotatsu.details.ui
import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
@@ -33,7 +38,6 @@ 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.ext.printStackTraceDebug
import java.io.IOException
class DetailsViewModel(
intent: MangaIntent,
@@ -91,8 +95,8 @@ class DetailsViewModel(
if (description.isNullOrEmpty()) {
emit(null)
} else {
emit(description.parseAsHtml())
emit(description.parseAsHtml(imageGetter = imageGetter))
emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
@@ -110,7 +114,7 @@ class DetailsViewModel(
val selectedBranchIndex = combine(
branches.asFlow(),
delegate.selectedBranch
delegate.selectedBranch,
) { branches, selected ->
branches.indexOf(selected)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
@@ -225,7 +229,7 @@ class DetailsViewModel(
fun unregisterScrobbling() {
launchJob(Dispatchers.Default) {
scrobbler.unregisterScrobbling(
mangaId = delegate.mangaId
mangaId = delegate.mangaId,
)
}
}
@@ -242,4 +246,13 @@ class DetailsViewModel(
it.chapter.name.contains(query, ignoreCase = true)
}
}
private fun Spanned.filterSpans(): CharSequence {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
for (span in spans) {
spannable.removeSpan(span)
}
return spannable.trim()
}
}

View File

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -16,9 +16,6 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class MangaDetailsDelegate(
@@ -45,12 +42,7 @@ class MangaDetailsDelegate(
manga = MangaRepository(manga.source).getDetails(manga)
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) {
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else {
predictBranch(manga.chapters)
}
selectedBranch.value = manga.getPreferredBranch(hist)
mangaData.value = manga
relatedManga.value = runCatching {
if (manga.source == MangaSource.LOCAL) {
@@ -91,7 +83,7 @@ class MangaDetailsDelegate(
val dateFormat = settings.getDateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapToSet { it.id }
val downloadedIds = downloadedChapters?.mapTo(HashSet(downloadedChapters.size)) { it.id }
for (i in chapters.indices) {
val chapter = chapters[i]
if (chapter.branch != branch) {
@@ -106,6 +98,9 @@ class MangaDetailsDelegate(
dateFormat = dateFormat,
)
}
if (result.size < chapters.size / 2) {
result.trimToSize()
}
return result
}
@@ -161,24 +156,9 @@ class MangaDetailsDelegate(
}
result.sortBy { it.chapter.number }
}
if (result.size < sourceChapters.size / 2) {
result.trimToSize()
}
return result
}
private fun predictBranch(chapters: List<MangaChapter>?): String? {
if (chapters.isNullOrEmpty()) {
return null
}
val groups = chapters.groupBy { it.branch }
for (locale in LocaleListCompat.getAdjustedDefault()) {
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.getDisplayName(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
}
return groups.maxByOrNull { it.value.size }?.key
}
}

View File

@@ -1,13 +1,24 @@
package org.koitharu.kotatsu.details.ui.model
import java.text.DateFormat
import org.koitharu.kotatsu.parsers.model.MangaChapter
class ChapterListItem(
val chapter: MangaChapter,
val flags: Int,
val uploadDate: String?,
private val uploadDateMs: Long,
private val dateFormat: DateFormat,
) {
var uploadDate: String? = null
private set
get() {
if (field != null) return field
if (uploadDateMs == 0L) return null
field = dateFormat.format(uploadDateMs)
return field
}
val status: Int
get() = flags and MASK_STATUS
@@ -32,7 +43,8 @@ class ChapterListItem(
if (chapter != other.chapter) return false
if (flags != other.flags) return false
if (uploadDate != other.uploadDate) return false
if (uploadDateMs != other.uploadDateMs) return false
if (dateFormat != other.dateFormat) return false
return true
}
@@ -40,7 +52,8 @@ class ChapterListItem(
override fun hashCode(): Int {
var result = chapter.hashCode()
result = 31 * result + flags
result = 31 * result + (uploadDate?.hashCode() ?: 0)
result = 31 * result + uploadDateMs.hashCode()
result = 31 * result + dateFormat.hashCode()
return result
}
@@ -53,4 +66,4 @@ class ChapterListItem(
const val FLAG_DOWNLOADED = 32
const val MASK_STATUS = FLAG_UNREAD or FLAG_CURRENT
}
}
}

View File

@@ -1,12 +1,12 @@
package org.koitharu.kotatsu.details.ui.model
import java.text.DateFormat
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
import org.koitharu.kotatsu.parsers.model.MangaChapter
import java.text.DateFormat
fun MangaChapter.toListItem(
isCurrent: Boolean,
@@ -25,6 +25,7 @@ fun MangaChapter.toListItem(
return ChapterListItem(
chapter = this,
flags = flags,
uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null
uploadDateMs = uploadDate,
dateFormat = dateFormat,
)
}

View File

@@ -1,21 +1,24 @@
package org.koitharu.kotatsu.download.domain
import android.content.Context
import android.net.ConnectivityManager
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import java.io.File
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.PausingHandle
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
@@ -25,11 +28,9 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 200L
@@ -43,14 +44,11 @@ class DownloadManager(
private val settings: AppSettings,
) {
private val connectivityManager = context.getSystemService(
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width
androidx.core.R.dimen.compat_notification_large_icon_max_width,
)
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height
androidx.core.R.dimen.compat_notification_large_icon_max_height,
)
private val semaphore = Semaphore(settings.downloadsParallelism)
@@ -58,114 +56,142 @@ class DownloadManager(
manga: Manga,
chaptersIds: LongArray?,
startId: Int,
): ProgressJob<DownloadState> {
): PausingProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null)
DownloadState.Queued(startId = startId, manga = manga, cover = null),
)
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
return ProgressJob(job, stateFlow)
val pausingHandle = PausingHandle()
val job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(stateFlow)) {
try {
downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
} catch (e: CancellationException) { // handle cancellation if not handled already
val state = stateFlow.value
if (state !is DownloadState.Cancelled) {
stateFlow.value = DownloadState.Cancelled(startId, state.manga, state.cover)
}
throw e
}
}
return PausingProgressJob(job, stateFlow, pausingHandle)
}
private fun downloadMangaImpl(
private suspend fun downloadMangaImpl(
manga: Manga,
chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
@Suppress("NAME_SHADOWING") var manga = manga
) {
@Suppress("NAME_SHADOWING")
var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover)
localMangaRepository.lockManga(manga.id)
semaphore.acquire()
coroutineContext[WakeLockNode]?.acquire()
outState.value = DownloadState.Preparing(startId, manga, null)
val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$startId.tmp"
var output: CbzMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
}
val repo = MangaRepository(manga.source)
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = CbzMangaOutput.get(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = checkNotNull(
if (chaptersIdsSet == null) {
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
}
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = repo.getPages(chapter)
for ((pageIndex, page) in pages.withIndex()) {
var retryCounter = 0
failsafe@ while (true) {
try {
val url = repo.getPageUrl(page)
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
withMangaLock(manga) {
semaphore.withPermit {
outState.value = DownloadState.Preparing(startId, manga, null)
val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$startId.tmp"
var output: CbzMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance")
}
val repo = MangaRepository(manga.source)
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = CbzMangaOutput.get(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = checkNotNull(
if (chaptersIdsSet == null) {
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
},
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = runFailsafe(outState, pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
outState.value = DownloadState.Progress(
startId = startId,
manga = data,
cover = cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
)
break@failsafe
} catch (e: IOException) {
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
delay(DOWNLOAD_ERROR_DELAY)
connectivityManager.waitForNetwork()
retryCounter++
} else {
throw e
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
}
}
outState.value = DownloadState.Progress(
startId, data, cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
)
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting()
output.finalize()
val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally {
withContext(NonCancellable) {
output?.closeQuietly()
output?.cleanup()
File(destination, tempFileName).deleteAwait()
}
}
}
outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting()
output.finalize()
val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e)
} finally {
withContext(NonCancellable) {
output?.cleanup()
File(destination, tempFileName).deleteAwait()
}
}
private suspend fun <R> runFailsafe(
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
block: suspend () -> R,
): R {
var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) {
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
val state = outState.value
outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true)
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
outState.value = state
} else {
countDown--
delay(DOWNLOAD_ERROR_DELAY)
}
}
coroutineContext[WakeLockNode]?.release()
semaphore.release()
localMangaRepository.unlockManga(manga.id)
}
}
@@ -189,12 +215,14 @@ class DownloadManager(
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
val prevValue = outState.value
outState.value = DownloadState.Error(
startId = prevValue.startId,
manga = prevValue.manga,
cover = prevValue.cover,
error = throwable,
canRetry = false,
)
}
@@ -205,10 +233,17 @@ class DownloadManager(
.referer(manga.publicUrl)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build()
.build(),
).drawable
}.getOrNull()
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
class Factory(
private val context: Context,
private val imageLoader: ImageLoader,

View File

@@ -108,33 +108,6 @@ sealed interface DownloadState {
}
}
class WaitingForNetwork(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as WaitingForNetwork
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class Done(
override val startId: Int,
override val manga: Manga,
@@ -170,6 +143,7 @@ sealed interface DownloadState {
override val manga: Manga,
override val cover: Drawable?,
val error: Throwable,
val canRetry: Boolean,
) : DownloadState {
override fun equals(other: Any?): Boolean {
@@ -182,6 +156,7 @@ sealed interface DownloadState {
if (manga != other.manga) return false
if (cover != other.cover) return false
if (error != other.error) return false
if (canRetry != other.canRetry) return false
return true
}
@@ -191,6 +166,7 @@ sealed interface DownloadState {
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + error.hashCode()
result = 31 * result + canRetry.hashCode()
return result
}
}

View File

@@ -1,25 +0,0 @@
package org.koitharu.kotatsu.download.domain
import android.os.PowerManager
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
class WakeLockNode(
private val wakeLock: PowerManager.WakeLock,
private val timeout: Long,
) : AbstractCoroutineContextElement(Key) {
init {
wakeLock.setReferenceCounted(true)
}
fun acquire() {
wakeLock.acquire(timeout)
}
fun release() {
wakeLock.release()
}
companion object Key : CoroutineContext.Key<WakeLockNode>
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.download.ui
import android.view.View
import androidx.core.view.isVisible
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -9,21 +10,33 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.progress.ProgressJob
fun downloadItemAD(
scope: CoroutineScope,
coil: ImageLoader,
) = adapterDelegateViewBinding<ProgressJob<DownloadState>, ProgressJob<DownloadState>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) = adapterDelegateViewBinding<DownloadItem, DownloadItem, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) },
) {
var job: Job? = null
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val clickListener = View.OnClickListener { v ->
when (v.id) {
R.id.button_cancel -> item.cancel()
R.id.button_resume -> item.resume()
else -> context.startActivity(
DetailsActivity.newIntent(context, item.progressValue.manga),
)
}
}
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
bind {
job?.cancel()
job = item.progressAsFlow().onFirst { state ->
@@ -44,6 +57,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Done -> {
binding.textViewStatus.setText(R.string.download_complete)
@@ -51,6 +66,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Error -> {
binding.textViewStatus.setText(R.string.error_occurred)
@@ -59,6 +76,8 @@ fun downloadItemAD(
binding.textViewPercent.isVisible = false
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true
binding.buttonCancel.isVisible = state.canRetry
binding.buttonResume.isVisible = state.canRetry
}
is DownloadState.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_)
@@ -66,6 +85,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_)
@@ -73,6 +94,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
@@ -83,6 +106,8 @@ fun downloadItemAD(
binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1))
binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Queued -> {
binding.textViewStatus.setText(R.string.queued)
@@ -90,13 +115,8 @@ fun downloadItemAD(
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadState.WaitingForNetwork -> {
binding.textViewStatus.setText(R.string.waiting_for_network)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
}
}.launchIn(scope)

View File

@@ -1,21 +1,23 @@
package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.bindServiceWithLifecycle
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@@ -26,30 +28,63 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
val adapter = DownloadsAdapter(lifecycleScope, get())
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
bindServiceWithLifecycle(
owner = this,
service = Intent(this, DownloadService::class.java),
flags = 0,
).service.flatMapLatest { binder ->
(binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
}.onEach {
adapter.items = it?.toList().orEmpty()
binding.textViewHolder.isVisible = it.isNullOrEmpty()
}.launchIn(lifecycleScope)
val connection = DownloadServiceConnection(adapter)
bindService(Intent(this, DownloadService::class.java), connection, 0)
lifecycle.addObserver(connection)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
bottom = insets.bottom,
)
binding.toolbar.updatePadding(
left = insets.left,
right = insets.right
right = insets.right,
)
}
private inner class DownloadServiceConnection(
private val adapter: DownloadsAdapter,
) : ServiceConnection, DefaultLifecycleObserver {
private var collectJob: Job? = null
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
collectJob?.cancel()
val binder = (service as? DownloadService.DownloadBinder)
collectJob = if (binder == null) {
null
} else {
lifecycleScope.launch {
binder.downloads.collect {
setItems(it)
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
collectJob?.cancel()
collectJob = null
setItems(null)
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
collectJob?.cancel()
collectJob = null
owner.lifecycle.removeObserver(this)
unbindService(this)
}
private fun setItems(items: Collection<DownloadItem>?) {
adapter.items = items?.toList().orEmpty()
binding.textViewHolder.isVisible = items.isNullOrEmpty()
}
}
companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)

View File

@@ -5,12 +5,14 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.utils.progress.ProgressJob
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
typealias DownloadItem = PausingProgressJob<DownloadState>
class DownloadsAdapter(
scope: CoroutineScope,
coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadState>>(DiffCallback()) {
) : AsyncListDifferDelegationAdapter<DownloadItem>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(scope, coil))
@@ -21,18 +23,18 @@ class DownloadsAdapter(
return items[position].progressValue.startId.toLong()
}
private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadState>>() {
private class DiffCallback : DiffUtil.ItemCallback<DownloadItem>() {
override fun areItemsTheSame(
oldItem: ProgressJob<DownloadState>,
newItem: ProgressJob<DownloadState>,
oldItem: DownloadItem,
newItem: DownloadItem,
): Boolean {
return oldItem.progressValue.startId == newItem.progressValue.startId
}
override fun areContentsTheSame(
oldItem: ProgressJob<DownloadState>,
newItem: ProgressJob<DownloadState>,
oldItem: DownloadItem,
newItem: DownloadItem,
): Boolean {
return oldItem.progressValue == newItem.progressValue
}

View File

@@ -7,33 +7,34 @@ import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.text.format.DateUtils
import android.util.SparseArray
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.parseAsHtml
import androidx.core.util.forEach
import androidx.core.util.isNotEmpty
import androidx.core.util.size
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DownloadNotification(private val context: Context, startId: Int) {
class DownloadNotification(private val context: Context) {
private val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val states = SparseArray<DownloadState>()
private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel),
PendingIntent.getBroadcast(
context,
startId,
DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
private val listIntent = PendingIntent.getActivity(
context,
REQUEST_LIST,
@@ -42,114 +43,271 @@ class DownloadNotification(private val context: Context, startId: Int) {
)
init {
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true)
groupBuilder.setOnlyAlertOnce(true)
groupBuilder.setDefaults(0)
groupBuilder.color = ContextCompat.getColor(context, R.color.blue_primary)
groupBuilder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
groupBuilder.setSilent(true)
groupBuilder.setGroup(GROUP_ID)
groupBuilder.setContentIntent(listIntent)
groupBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
groupBuilder.setGroupSummary(true)
groupBuilder.setContentTitle(context.getString(R.string.downloading_manga))
}
fun create(state: DownloadState, timeLeft: Long): Notification {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(listIntent)
builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
builder.setVisibility(
fun buildGroupNotification(): Notification {
val style = NotificationCompat.InboxStyle(groupBuilder)
var progress = 0f
var isAllDone = true
var isInProgress = false
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
states.forEach { _, state ->
if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
}
)
when (state) {
is DownloadState.Cancelled -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.cancelling_))
builder.setContentIntent(null)
builder.setStyle(null)
builder.setOngoing(true)
}
is DownloadState.Done -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga))
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
}
is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setAutoCancel(true)
builder.setOngoing(false)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null)
builder.setOngoing(true)
}
is DownloadState.Queued -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadState.Preparing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta)
} else {
val percent = (state.percent * 100).format()
builder.setContentText(context.getString(R.string.percent_string_pattern, percent))
val summary = when (state) {
is DownloadState.Cancelled -> {
progress++
context.getString(R.string.cancelling_)
}
is DownloadState.Done -> {
progress++
context.getString(R.string.download_complete)
}
is DownloadState.Error -> {
isAllDone = false
context.getString(R.string.error)
}
is DownloadState.PostProcessing -> {
progress++
isInProgress = true
isAllDone = false
context.getString(R.string.processing_)
}
is DownloadState.Preparing -> {
isAllDone = false
isInProgress = true
context.getString(R.string.preparing_)
}
is DownloadState.Progress -> {
isAllDone = false
isInProgress = true
progress += state.percent
context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
}
is DownloadState.Queued -> {
isAllDone = false
isInProgress = true
context.getString(R.string.queued)
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadState.WaitingForNetwork -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
style.addLine(
context.getString(
R.string.download_summary_pattern,
state.manga.title.ellipsize(16).htmlEncode(),
summary.htmlEncode(),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY),
)
}
return builder.build()
progress = if (isInProgress) {
progress / states.size.toFloat()
} else {
1f
}
style.setBigContentTitle(
context.getString(if (isAllDone) R.string.download_complete else R.string.downloading_manga),
)
groupBuilder.setContentText(context.resources.getQuantityString(R.plurals.items, states.size, states.size()))
groupBuilder.setNumber(states.size)
groupBuilder.setSmallIcon(
if (isInProgress) android.R.drawable.stat_sys_download else android.R.drawable.stat_sys_download_done,
)
groupBuilder.setAutoCancel(isAllDone)
when (progress) {
1f -> groupBuilder.setProgress(0, 0, false)
0f -> groupBuilder.setProgress(1, 0, true)
else -> groupBuilder.setProgress(100, (progress * 100f).toInt(), false)
}
return groupBuilder.build()
}
fun detach() {
if (states.isNotEmpty()) {
val notification = buildGroupNotification()
manager.notify(ID_GROUP_DETACHED, notification)
}
manager.cancel(ID_GROUP)
}
fun newItem(startId: Int) = Item(startId)
inner class Item(
private val startId: Int,
) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action(
materialR.drawable.material_ic_clear_black_24dp,
context.getString(android.R.string.cancel),
PendingIntent.getBroadcast(
context,
startId * 2,
DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
),
)
private val retryAction = NotificationCompat.Action(
R.drawable.ic_restart_black,
context.getString(R.string.try_again),
PendingIntent.getBroadcast(
context,
startId * 2 + 1,
DownloadService.getResumeIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
),
)
init {
builder.setOnlyAlertOnce(true)
builder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true)
builder.setGroup(GROUP_ID)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
fun notify(state: DownloadState, timeLeft: Long) {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
builder.setSmallIcon(android.R.drawable.stat_sys_download)
builder.setContentIntent(listIntent)
builder.setStyle(null)
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
builder.setSubText(null)
builder.setShowWhen(false)
builder.setVisibility(
if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
},
)
when (state) {
is DownloadState.Cancelled -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.cancelling_))
builder.setContentIntent(null)
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Done -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.download_complete))
builder.setContentIntent(createMangaIntent(context, state.localManga))
builder.setAutoCancel(true)
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources)
builder.setProgress(0, 0, false)
builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error))
builder.setContentText(message)
builder.setAutoCancel(!state.canRetry)
builder.setOngoing(state.canRetry)
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setShowWhen(true)
builder.setWhen(System.currentTimeMillis())
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
if (state.canRetry) {
builder.addAction(cancelAction)
builder.addAction(retryAction)
}
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Queued -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.queued))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_LOW
}
is DownloadState.Preparing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.preparing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta)
builder.setSubText(percent)
} else {
builder.setContentText(percent)
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
}
val notification = builder.build()
states.append(startId, state)
updateGroupNotification()
manager.notify(TAG, startId, notification)
}
fun dismiss() {
manager.cancel(TAG, startId)
states.remove(startId)
updateGroupNotification()
}
}
private fun updateGroupNotification() {
val notification = buildGroupNotification()
manager.notify(ID_GROUP, notification)
}
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
context,
manga.hashCode(),
DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
)
companion object {
private const val TAG = "download"
private const val CHANNEL_ID = "download"
private const val GROUP_ID = "downloads"
private const val REQUEST_LIST = 6
const val ID_GROUP = 9999
private const val ID_GROUP_DETACHED = 9998
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -158,7 +316,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW
NotificationManager.IMPORTANCE_LOW,
)
channel.enableVibration(false)
channel.enableLights(false)

View File

@@ -8,12 +8,16 @@ import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koin.android.ext.android.get
import org.koin.core.context.GlobalContext
import org.koitharu.kotatsu.BuildConfig
@@ -24,35 +28,37 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.domain.WakeLockNode
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import org.koitharu.kotatsu.utils.progress.ProgressJob
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
import java.util.concurrent.TimeUnit
class DownloadService : BaseService() {
private lateinit var downloadManager: DownloadManager
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher
private lateinit var downloadNotification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadState>>()
private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>()
private val jobCount = MutableStateFlow(0)
private val controlReceiver = ControlReceiver()
private var binder: DownloadBinder? = null
override fun onCreate() {
super.onCreate()
isRunning = true
notificationSwitcher = ForegroundNotificationSwitcher(this)
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
downloadNotification = DownloadNotification(this)
wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = get<DownloadManager.Factory>().create(
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
)
wakeLock.acquire(TimeUnit.HOURS.toMillis(8))
downloadManager = get<DownloadManager.Factory>().create(lifecycleScope)
DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())
val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
registerReceiver(controlReceiver, intentFilter)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -64,24 +70,19 @@ class DownloadService : BaseService() {
jobCount.value = jobs.size
START_REDELIVER_INTENT
} else {
stopSelf(startId)
stopSelfIfIdle()
START_NOT_STICKY
}
}
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return binder ?: DownloadBinder(this).also { binder = it }
}
override fun onUnbind(intent: Intent?): Boolean {
binder = null
return super.onUnbind(intent)
return DownloadBinder(this)
}
override fun onDestroy() {
unregisterReceiver(controlReceiver)
binder = null
wakeLock.release()
isRunning = false
super.onDestroy()
}
@@ -90,7 +91,7 @@ class DownloadService : BaseService() {
startId: Int,
manga: Manga,
chaptersIds: LongArray?,
): ProgressJob<DownloadState> {
): PausingProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job)
return job
@@ -99,10 +100,10 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch {
val startId = job.progressValue.startId
val notification = DownloadNotification(this@DownloadService, startId)
val notificationItem = downloadNotification.newItem(startId)
try {
val timeLeftEstimator = TimeLeftEstimator()
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
notificationItem.notify(job.progressValue, -1L)
job.progressAsFlow()
.onEach { state ->
if (state is DownloadState.Progress) {
@@ -115,26 +116,27 @@ class DownloadService : BaseService() {
.whileActive()
.collect { state ->
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
notificationItem.notify(state, timeLeft)
}
job.join()
} finally {
(job.progressValue as? DownloadState.Done)?.let {
sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)),
)
}
notificationSwitcher.detach(
startId,
if (job.isCancelled) {
null
} else {
notification.create(job.progressValue, -1L)
if (job.isCancelled) {
notificationItem.dismiss()
if (jobs.remove(startId) != null) {
jobCount.value = jobs.size
}
)
stopSelf(startId)
} else {
notificationItem.notify(job.progressValue, -1L)
}
}
}.invokeOnCompletion {
stopSelfIfIdle()
}
}
@@ -144,7 +146,17 @@ class DownloadService : BaseService() {
}
private val DownloadState.isTerminal: Boolean
get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled
get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry)
@MainThread
private fun stopSelfIfIdle() {
if (jobs.any { (_, job) -> job.isActive }) {
return
}
downloadNotification.detach()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
inner class ControlReceiver : BroadcastReceiver() {
@@ -152,17 +164,37 @@ class DownloadService : BaseService() {
when (intent?.action) {
ACTION_DOWNLOAD_CANCEL -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs.remove(cancelId)?.cancel()
jobCount.value = jobs.size
jobs[cancelId]?.cancel()
// jobs.remove(cancelId)?.cancel()
// jobCount.value = jobs.size
}
ACTION_DOWNLOAD_RESUME -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs[cancelId]?.resume()
}
}
}
}
class DownloadBinder(private val service: DownloadService) : Binder() {
class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver {
val downloads: Flow<Collection<ProgressJob<DownloadState>>>
get() = service.jobCount.mapLatest { service.jobs.values }
private var downloadsStateFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
init {
service.lifecycle.addObserver(this)
service.jobCount.onEach {
downloadsStateFlow.value = service.jobs.values.toList()
}.launchIn(service.lifecycleScope)
}
override fun onDestroy(owner: LifecycleOwner) {
owner.lifecycle.removeObserver(this)
downloadsStateFlow.value = emptyList()
super.onDestroy(owner)
}
val downloads
get() = downloadsStateFlow.asStateFlow()
}
companion object {
@@ -173,6 +205,7 @@ class DownloadService : BaseService() {
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME"
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
@@ -219,6 +252,9 @@ class DownloadService : BaseService() {
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getDownloadedManga(intent: Intent?): Manga? {
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga

View File

@@ -1,62 +0,0 @@
package org.koitharu.kotatsu.download.ui.service
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.SparseArray
import androidx.core.app.ServiceCompat
import androidx.core.util.isEmpty
import androidx.core.util.size
private const val DEFAULT_DELAY = 500L
class ForegroundNotificationSwitcher(
private val service: Service,
) {
private val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val notifications = SparseArray<Notification>()
private val handler = Handler(Looper.getMainLooper())
@Synchronized
fun notify(startId: Int, notification: Notification) {
if (notifications.isEmpty()) {
service.startForeground(startId, notification)
} else {
notificationManager.notify(startId, notification)
}
notifications[startId] = notification
}
@Synchronized
fun detach(startId: Int, notification: Notification?) {
notifications.remove(startId)
if (notifications.isEmpty()) {
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_DETACH)
}
val nextIndex = notifications.size - 1
if (nextIndex >= 0) {
val nextStartId = notifications.keyAt(nextIndex)
val nextNotification = notifications.valueAt(nextIndex)
service.startForeground(nextStartId, nextNotification)
}
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
}
private inner class NotifyRunnable(
private val startId: Int,
private val notification: Notification?,
) : Runnable {
override fun run() {
if (notification != null) {
notificationManager.notify(startId, notification)
} else {
notificationManager.cancel(startId)
}
}
}
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.download.ui.service
import androidx.annotation.AnyThread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
class PausingHandle {
private val paused = MutableStateFlow(false)
@get:AnyThread
val isPaused: Boolean
get() = paused.value
@AnyThread
suspend fun awaitResumed() {
paused.filter { !it }.first()
}
@AnyThread
fun pause() {
paused.value = true
}
@AnyThread
fun resume() {
paused.value = false
}
}

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaGridItemAD(
coil: ImageLoader,
@@ -24,9 +25,8 @@ fun mangaGridItemAD(
clickListener: OnListItemClickListener<Manga>,
sizeResolver: ItemSizeResolver?,
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null
itemView.setOnClickListener {
@@ -46,6 +46,7 @@ fun mangaGridItemAD(
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
referer(item.manga.publicUrl)
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_placeholder)

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
@@ -12,15 +11,16 @@ import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaListDetailedItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null
itemView.setOnClickListener {
@@ -36,6 +36,7 @@ fun mangaListDetailedItemAD(
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
referer(item.manga.publicUrl)
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_placeholder)

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import org.koitharu.kotatsu.utils.ext.*
import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
@@ -11,15 +10,15 @@ import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.*
fun mangaListItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<MangaListModel, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
) {
var badge: BadgeDrawable? = null
itemView.setOnClickListener {

View File

@@ -48,6 +48,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
x.altTitle?.contains(query, ignoreCase = true) == true
}
}
list.sortWith(compareBy(AlphanumComparator()) { x -> x.title })
return list
}
@@ -61,6 +62,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
x.tags.containsAll(tags)
}
}
list.sortWith(compareBy(AlphanumComparator()) { x -> x.title })
return list
}
@@ -86,7 +88,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
entries.filter { x ->
!x.isDirectory && x.name.substringBeforeLast(
File.separatorChar,
""
"",
) == parent
}
}
@@ -138,11 +140,11 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
url = fileUri,
coverUrl = zipUri(
file,
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty()
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
),
chapters = info.chapters?.map { c ->
c.copy(url = fileUri, source = MangaSource.LOCAL)
}
},
)
}
// fallback
@@ -211,7 +213,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return@runInterruptible info.copy2(
source = MangaSource.LOCAL,
url = fileUri,
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
chapters = info.chapters?.map { c -> c.copy(url = fileUri) },
)
}
}
@@ -288,7 +290,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
locks.lock(id)
}
suspend fun unlockManga(id: Long) {
fun unlockManga(id: Long) {
locks.unlock(id)
}

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
class AppProtectHelper(private val settings: AppSettings) : Application.ActivityLifecycleCallbacks {
@@ -11,7 +12,7 @@ class AppProtectHelper(private val settings: AppSettings) : Application.Activity
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)

View File

@@ -14,7 +14,7 @@ val readerModule
factory { MangaDataRepository(get()) }
single { PagesCache(get()) }
factory { PageSaveHelper(get(), androidContext()) }
factory { PageSaveHelper(androidContext()) }
viewModel { params ->
ReaderViewModel(
@@ -25,7 +25,7 @@ val readerModule
historyRepository = get(),
settings = get(),
pageSaveHelper = get(),
bookmarksRepository = get(),
bookmarksRepository = get()
)
}
}

View File

@@ -4,6 +4,10 @@ import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.activity.result.ActivityResultLauncher
import androidx.core.net.toUri
import java.io.File
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -11,19 +15,14 @@ import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.IOException
import org.koitharu.kotatsu.base.domain.MangaUtils
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.reader.domain.PageLoader
import java.io.File
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
private const val MAX_FILENAME_LENGTH = 10
private const val EXTENSION_FALLBACK = "png"
class PageSaveHelper(
private val cache: PagesCache,
context: Context,
) {
@@ -61,7 +60,11 @@ class PageSaveHelper(
} != null
private suspend fun getProposedFileName(url: String, file: File): String {
var name = url.toHttpUrl().pathSegments.last()
var name = if (url.startsWith("cbz://")) {
requireNotNull(url.toUri().fragment)
} else {
url.toHttpUrl().pathSegments.last()
}
var extension = name.substringAfterLast('.', "")
name = name.substringBeforeLast('.')
if (extension.length !in 2..4) {

View File

@@ -19,6 +19,7 @@ import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -47,7 +48,6 @@ import org.koitharu.kotatsu.utils.GridTouchHelper
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.*
import java.util.concurrent.TimeUnit
class ReaderActivity :
BaseFullscreenActivity<ActivityReaderBinding>(),
@@ -67,6 +67,9 @@ class ReaderActivity :
)
}
override val readerMode: ReaderMode?
get() = readerManager.currentMode
private lateinit var touchHelper: GridTouchHelper
private lateinit var orientationHelper: ScreenOrientationHelper
private lateinit var controlDelegate: ReaderControlDelegate
@@ -82,7 +85,7 @@ class ReaderActivity :
supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this)
orientationHelper = ScreenOrientationHelper(this)
controlDelegate = ReaderControlDelegate(lifecycleScope, get(), this)
controlDelegate = ReaderControlDelegate(get(), this, this)
binding.toolbarBottom.inflateMenu(R.menu.opt_reader_bottom)
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
insetsDelegate.interceptingWindowInsetsListener = this
@@ -146,7 +149,7 @@ class ReaderActivity :
ChaptersBottomSheet.show(
supportFragmentManager,
viewModel.manga?.chapters.orEmpty(),
viewModel.getCurrentState()?.chapterId ?: 0L
viewModel.getCurrentState()?.chapterId ?: 0L,
)
}
R.id.action_screen_rotate -> {
@@ -317,12 +320,12 @@ class ReaderActivity :
binding.appbarTop.updatePadding(
top = systemBars.top,
right = systemBars.right,
left = systemBars.left
left = systemBars.left,
)
binding.appbarBottom?.updatePadding(
bottom = systemBars.bottom,
right = systemBars.right,
left = systemBars.left
left = systemBars.left,
)
return WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)

View File

@@ -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 listener: OnInteractionListener
) {
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,17 +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
@@ -97,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()

View File

@@ -7,6 +7,8 @@ import android.view.View
import androidx.core.view.isVisible
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
@@ -26,6 +28,8 @@ open class PageHolder(
View.OnClickListener {
init {
binding.ssiv.setExecutor(Dispatchers.Default.asExecutor())
binding.ssiv.setEagerLoadingEnabled(!isLowRamDevice(context))
binding.ssiv.setOnImageEventListener(delegate)
@Suppress("LeakingThis")
bindingInfo.buttonRetry.setOnClickListener(this)

View File

@@ -3,13 +3,16 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import org.koitharu.kotatsu.R
class WebtoonFrameLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) {
private val target by lazy {
private val target by lazy(LazyThreadSafetyMode.NONE) {
findViewById<WebtoonImageView>(R.id.ssiv)
}

View File

@@ -13,14 +13,14 @@ import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
import org.koitharu.kotatsu.utils.ext.*
class WebtoonHolder(
binding: ItemPageWebtoonBinding,
loader: PageLoader,
settings: AppSettings,
exceptionResolver: ExceptionResolver
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver),
View.OnClickListener {
@@ -29,6 +29,7 @@ class WebtoonHolder(
init {
binding.ssiv.setOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this)
GoneOnInvisibleListener(bindingInfo.progressBar).attach()
}
override fun onBind(data: ReaderPage) {
@@ -61,9 +62,9 @@ class WebtoonHolder(
override fun onImageShowing(zoom: ZoomMode) {
with(binding.ssiv) {
maxScale = 2f * width / sWidth.toFloat()
setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM)
minScale = width / sWidth.toFloat()
maxScale = minScale
scrollTo(
when {
scrollToRestore != 0 -> scrollToRestore

View File

@@ -1,11 +1,15 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.app.Activity
import android.content.Context
import android.graphics.PointF
import android.util.AttributeSet
import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.parsers.util.toIntUp
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.ext.parents
private const val SCROLL_UNKNOWN = -1
@@ -15,15 +19,15 @@ class WebtoonImageView @JvmOverloads constructor(
) : SubsamplingScaleImageView(context, attr) {
private val ct = PointF()
private val displayHeight = if (context is Activity) {
context.window.decorView.height
} else {
context.resources.displayMetrics.heightPixels
}
private var scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN
init {
setExecutor(Dispatchers.Default.asExecutor())
setEagerLoadingEnabled(!isLowRamDevice(context))
}
fun scrollBy(delta: Int) {
val maxScroll = getScrollRange()
if (maxScroll == 0) {
@@ -36,6 +40,7 @@ class WebtoonImageView @JvmOverloads constructor(
fun scrollTo(y: Int) {
val maxScroll = getScrollRange()
if (maxScroll == 0) {
resetScaleAndCenter()
return
}
scrollToInternal(y.coerceIn(0, maxScroll))
@@ -58,8 +63,11 @@ class WebtoonImageView @JvmOverloads constructor(
override fun getSuggestedMinimumHeight(): Int {
var desiredHeight = super.getSuggestedMinimumHeight()
if (sHeight == 0 && desiredHeight < displayHeight) {
desiredHeight = displayHeight
if (sHeight == 0) {
val parentHeight = parentHeight()
if (desiredHeight < parentHeight) {
desiredHeight = parentHeight
}
}
return desiredHeight
}
@@ -84,7 +92,7 @@ class WebtoonImageView @JvmOverloads constructor(
}
}
width = width.coerceAtLeast(suggestedMinimumWidth)
height = height.coerceIn(suggestedMinimumHeight, displayHeight)
height = height.coerceIn(suggestedMinimumHeight, parentHeight())
setMeasuredDimension(width, height)
}
@@ -101,4 +109,8 @@ class WebtoonImageView @JvmOverloads constructor(
val totalHeight = (sHeight * minScale).toIntUp()
scrollRange = (totalHeight - height).coerceAtLeast(0)
}
private fun parentHeight(): Int {
return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
}
}

View File

@@ -6,6 +6,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.sign
@Suppress("unused")
class WebtoonLayoutManager : LinearLayoutManager {
private var scrollDirection: Int = 0

View File

@@ -23,7 +23,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = FragmentReaderWebtoonBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
import android.graphics.drawable.Drawable
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import coil.size.Size
import com.google.android.material.R as materialR
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -24,7 +25,6 @@ fun pageThumbnailAD(
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }
) {
var job: Job? = null
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
val thumbSize = Size(
@@ -39,6 +39,7 @@ fun pageThumbnailAD(
.data(url)
.referer(item.page.referer)
.size(thumbSize)
.scale(Scale.FILL)
.allowRgb565(true)
.build()
).drawable

View File

@@ -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
}
}
}

View File

@@ -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()

View File

@@ -33,7 +33,7 @@ class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLay
fun setTitle(title: CharSequence?) {
currentTitle = title
if (slidingPaneLayout.isOpen) {
if (slidingPaneLayout.width != 0 && slidingPaneLayout.isOpen) {
activity?.title = title
}
}

View File

@@ -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 kotlinx.coroutines.Dispatchers
@@ -47,7 +49,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(KEY_AUTH)?.run {
if (isVisible) {
loadUsername(this)
loadUsername(viewLifecycleOwner, this)
}
}
}
@@ -62,7 +64,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) {
@@ -89,11 +91,12 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
}
}
private fun resolveError(error: Throwable): Unit {
private fun resolveError(error: Throwable) {
viewLifecycleScope.launch {
if (exceptionResolver.resolve(error)) {
val pref = findPreference<Preference>(KEY_AUTH) ?: return@launch
loadUsername(pref)
val lifecycleOwner = awaitViewLifecycle()
loadUsername(lifecycleOwner, pref)
}
}
}

View File

@@ -33,15 +33,22 @@ class NewSourcesViewModel(
private fun buildList() {
val locales = LocaleListCompat.getDefault().mapToSet { it.language }
val hidden = settings.hiddenSources
val pendingHidden = HashSet<String>()
sources.value = initialList.map {
val locale = it.locale
val isEnabledByLocale = locale == null || locale in locales
if (!isEnabledByLocale) {
pendingHidden += it.name
}
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = it.name !in hidden && (locale == null || locale in locales),
isDraggable = false,
isEnabled = isEnabledByLocale,
isDraggable = false
)
}
if (pendingHidden.isNotEmpty()) {
settings.hiddenSources += pendingHidden
}
}
}

View File

@@ -3,15 +3,16 @@ package org.koitharu.kotatsu.settings.onboard
import androidx.collection.ArraySet
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData
import java.util.*
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.*
class OnboardViewModel(
private val settings: AppSettings,
@@ -27,7 +28,7 @@ class OnboardViewModel(
init {
if (settings.isSourcesSelected) {
selectedLocales.removeAll(settings.hiddenSources.mapToSet { x -> MangaSource.valueOf(x).locale })
selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x)?.locale })
} else {
val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
x.language
@@ -66,7 +67,7 @@ class OnboardViewModel(
SourceLocale(
key = key,
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
isChecked = key in selectedLocales
isChecked = key in selectedLocales,
)
}.sortedWith(SourceLocaleComparator())
}

View File

@@ -5,8 +5,8 @@ import android.content.res.TypedArray
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.view.View
import androidx.core.content.withStyledAttributes
import androidx.customview.view.AbsSavedState
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import com.google.android.material.slider.Slider
@@ -40,11 +40,11 @@ class SliderPreference @JvmOverloads constructor(
attrs,
R.styleable.SliderPreference,
defStyleAttr,
defStyleRes
defStyleRes,
) {
valueFrom = getFloat(
R.styleable.SliderPreference_android_valueFrom,
valueFrom.toFloat()
valueFrom.toFloat(),
).toInt()
valueTo =
getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt()
@@ -117,7 +117,7 @@ class SliderPreference @JvmOverloads constructor(
}
}
private class SavedState : View.BaseSavedState {
private class SavedState : AbsSavedState {
val valueFrom: Int
val valueTo: Int
@@ -134,7 +134,7 @@ class SliderPreference @JvmOverloads constructor(
this.currentValue = currentValue
}
constructor(source: Parcel) : super(source) {
constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
valueFrom = source.readInt()
valueTo = source.readInt()
currentValue = source.readInt()
@@ -148,9 +148,10 @@ class SliderPreference @JvmOverloads constructor(
}
companion object {
@Suppress("unused")
@JvmField
val CREATOR: Parcelable.Creator<SavedState> = object : Parcelable.Creator<SavedState> {
override fun createFromParcel(`in`: Parcel) = SavedState(`in`)
override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
}

View File

@@ -14,7 +14,7 @@ val trackerModule
factory { TrackingRepository(get()) }
factory { TrackerNotificationChannels(androidContext(), get()) }
factory { Tracker(get(), get(), get()) }
factory { Tracker(get(), get(), get(), get()) }
viewModel { FeedViewModel(get()) }
}

View File

@@ -1,8 +1,10 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository
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.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
@@ -12,6 +14,7 @@ import org.koitharu.kotatsu.tracker.work.TrackingItem
class Tracker(
private val settings: AppSettings,
private val repository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val channels: TrackerNotificationChannels,
) {
@@ -68,7 +71,7 @@ class Tracker(
suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates {
val manga = MangaRepository(track.manga.source).getDetails(track.manga)
val updates = compare(track, manga)
val updates = compare(track, manga, getBranch(manga))
if (commit) {
repository.saveUpdates(updates)
}
@@ -78,7 +81,7 @@ class Tracker(
@VisibleForTesting
suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates {
val track = repository.getTrack(manga)
val updates = compare(track, manga)
val updates = compare(track, manga, getBranch(manga))
if (commit) {
repository.saveUpdates(updates)
}
@@ -90,25 +93,30 @@ class Tracker(
repository.deleteTrack(mangaId)
}
private suspend fun getBranch(manga: Manga): String? {
val history = historyRepository.getOne(manga)
return manga.getPreferredBranch(history)
}
/**
* The main functionality of tracker: check new chapters in [manga] comparing to the [track]
*/
private fun compare(track: MangaTracking, manga: Manga): MangaUpdates {
private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates {
if (track.isEmpty()) {
// first check or manga was empty on last check
return MangaUpdates(manga, emptyList(), isValid = false)
}
val chapters = requireNotNull(manga.chapters)
val chapters = requireNotNull(manga.getChapters(branch))
val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId }
return when {
newChapters.isEmpty() -> {
return MangaUpdates(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId)
MangaUpdates(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId)
}
newChapters.size == chapters.size -> {
return MangaUpdates(manga, emptyList(), isValid = false)
MangaUpdates(manga, emptyList(), isValid = false)
}
else -> {
return MangaUpdates(manga, newChapters, isValid = true)
MangaUpdates(manga, newChapters, isValid = true)
}
}
}

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.ui.adapter.FeedAdapter
@@ -25,6 +26,7 @@ import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
class FeedFragment :
BaseFragment<FragmentFeedBinding>(),
@@ -39,7 +41,7 @@ class FeedFragment :
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = FragmentFeedBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -54,7 +56,7 @@ class FeedFragment :
paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
val decoration = TypedSpacingItemDecoration(
FeedAdapter.ITEM_TYPE_FEED to 0,
fallbackSpacing = spacing
fallbackSpacing = spacing,
)
addItemDecoration(decoration)
}
@@ -77,12 +79,25 @@ class FeedFragment :
override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.recyclerView.updatePadding(
top = headerHeight + paddingVertical,
left = insets.left + paddingHorizontal,
right = insets.right + paddingHorizontal,
bottom = insets.bottom + paddingVertical,
binding.root.updatePadding(
left = insets.left,
right = insets.right,
)
if (activity is MainActivity) {
binding.recyclerView.updatePadding(
top = headerHeight,
bottom = insets.bottom,
)
binding.swipeRefreshLayout.setProgressViewOffset(
true,
headerHeight + resources.resolveDp(-72),
headerHeight + resources.resolveDp(10),
)
} else {
binding.recyclerView.updatePadding(
bottom = insets.bottom,
)
}
}
override fun onRetryClick(error: Throwable) = Unit
@@ -101,7 +116,7 @@ class FeedFragment :
Snackbar.make(
binding.recyclerView,
R.string.updates_feed_cleared,
Snackbar.LENGTH_LONG
Snackbar.LENGTH_LONG,
).show()
}
@@ -109,7 +124,7 @@ class FeedFragment :
Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT
Snackbar.LENGTH_SHORT,
).show()
}

View File

@@ -16,12 +16,13 @@ import org.koitharu.kotatsu.list.ui.model.*
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.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
class FeedViewModel(
private val repository: TrackingRepository
private val repository: TrackingRepository,
) : BaseViewModel() {
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null)
@@ -32,7 +33,7 @@ class FeedViewModel(
val onFeedCleared = SingleLiveEvent<Unit>()
val content = combine(
logList.filterNotNull(),
hasNextPage
hasNextPage,
) { list, isHasNextPage ->
buildList(list.size + 2) {
if (list.isEmpty()) {
@@ -43,7 +44,7 @@ class FeedViewModel(
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.text_feed_holder,
actionStringRes = 0,
)
),
)
} else {
list.mapListTo(this)
@@ -54,7 +55,7 @@ class FeedViewModel(
}
}.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default,
listOf(header, LoadingState)
listOf(header, LoadingState),
)
init {

View File

@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.utils
import java.util.*
import kotlin.coroutines.coroutineContext
import kotlin.coroutines.resume
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.*
import kotlin.coroutines.resume
class CompositeMutex<T : Any> : Set<T> {
@@ -34,7 +34,7 @@ class CompositeMutex<T : Any> : Set<T> {
}
suspend fun lock(element: T) {
while (currentCoroutineContext().isActive) {
while (coroutineContext.isActive) {
waitForRemoval(element)
mutex.withLock {
if (data[element] == null) {
@@ -45,11 +45,9 @@ class CompositeMutex<T : Any> : Set<T> {
}
}
suspend fun unlock(element: T) {
val continuations = mutex.withLock {
checkNotNull(data.remove(element)) {
"CompositeMutex is not locked for $element"
}
fun unlock(element: T) {
val continuations = checkNotNull(data.remove(element)) {
"CompositeMutex is not locked for $element"
}
continuations.forEach { c ->
if (c.isActive) {

View File

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

View File

@@ -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
}
}
}

View File

@@ -1,45 +0,0 @@
package org.koitharu.kotatsu.utils
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class LifecycleAwareServiceConnection(
private val host: Activity,
) : ServiceConnection, DefaultLifecycleObserver {
private val serviceStateFlow = MutableStateFlow<IBinder?>(null)
val service: StateFlow<IBinder?>
get() = serviceStateFlow
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
serviceStateFlow.value = service
}
override fun onServiceDisconnected(name: ComponentName?) {
serviceStateFlow.value = null
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
host.unbindService(this)
}
}
fun Activity.bindServiceWithLifecycle(
owner: LifecycleOwner,
service: Intent,
flags: Int
): LifecycleAwareServiceConnection {
val connection = LifecycleAwareServiceConnection(this)
bindService(service, connection, flags)
owner.lifecycle.addObserver(connection)
return connection
}

View File

@@ -1,19 +1,17 @@
package org.koitharu.kotatsu.utils.ext
import android.app.ActivityManager
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.SharedPreferences
import android.content.pm.ResolveInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri
import android.os.Build
import androidx.activity.result.ActivityResultLauncher
import androidx.core.app.ActivityOptionsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker
import kotlin.coroutines.resume
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
@@ -22,32 +20,12 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
suspend fun ConnectivityManager.waitForNetwork(): Network {
val request = NetworkRequest.Builder().build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// fast path
activeNetwork?.let { return it }
}
return suspendCancellableCoroutine { cont ->
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
unregisterNetworkCallback(this)
if (cont.isActive) {
cont.resume(network)
}
}
}
registerNetworkCallback(request, callback)
cont.invokeOnCancellation {
unregisterNetworkCallback(callback)
}
}
}
val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
@@ -92,4 +70,8 @@ fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) {
delay(delay)
runnable.run()
}
}
fun isLowRamDevice(context: Context): Boolean {
return context.activityManager?.isLowRamDevice ?: false
}

View File

@@ -9,6 +9,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.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
@@ -45,9 +46,28 @@ 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 {
return listener(ImageRequestIndicatorListener(indicator))
}
private fun String.baseUrl(): String? {
return (this.toHttpUrlOrNull()?.newBuilder("/") ?: return null)
.username("")
.password("")
.build()
.toString()
}

View File

@@ -7,8 +7,12 @@ 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)
@@ -49,4 +53,20 @@ fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) {
fun Fragment.addMenuProvider(provider: MenuProvider) {
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
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)
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.utils.ext
import android.content.ActivityNotFoundException
import android.content.res.Resources
import java.net.SocketTimeoutException
import okio.FileNotFoundException
import org.acra.ktx.sendWithAcra
import org.koitharu.kotatsu.R
@@ -10,13 +11,13 @@ import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException
import java.net.SocketTimeoutException
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
is ActivityNotFoundException,
is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported)
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
@@ -37,5 +38,6 @@ fun Throwable.isReportable(): Boolean {
}
fun Throwable.report(message: String?) {
CaughtException(this, message).sendWithAcra()
val exception = CaughtException(this, message)
exception.sendWithAcra()
}

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
@@ -138,4 +139,13 @@ fun <T : View> ViewGroup.findViewsByType(clazz: Class<T>): Sequence<T> {
}
}
}
}
}
val View.parents: Sequence<ViewParent>
get() = sequence {
var p: ViewParent? = parent
while (p != null) {
yield(p)
p = p.parent
}
}

View File

@@ -0,0 +1,83 @@
package org.koitharu.kotatsu.utils.image
import android.view.View
import android.view.View.OnLayoutChangeListener
import android.view.ViewGroup
import android.widget.ImageView
import coil.size.Dimension
import coil.size.Size
import coil.size.SizeResolver
import kotlin.coroutines.resume
import kotlin.math.roundToInt
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
private const val ASPECT_RATIO_HEIGHT = 18f
private const val ASPECT_RATIO_WIDTH = 13f
class CoverSizeResolver(
private val imageView: ImageView,
) : SizeResolver {
override suspend fun size(): Size {
getSize()?.let { return it }
return suspendCancellableCoroutine { cont ->
val layoutListener = LayoutListener(cont)
imageView.addOnLayoutChangeListener(layoutListener)
cont.invokeOnCancellation {
imageView.removeOnLayoutChangeListener(layoutListener)
}
}
}
private fun getSize(): Size? {
val lp = imageView.layoutParams
var width = getDimension(lp.width, imageView.width, imageView.paddingLeft + imageView.paddingRight)
var height = getDimension(lp.height, imageView.height, imageView.paddingTop + imageView.paddingBottom)
if (width == null && height == null) {
return null
}
if (height == null && width != null) {
height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt())
} else if (width == null && height != null) {
width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt())
}
return Size(checkNotNull(width), checkNotNull(height))
}
private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? {
if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
return null
}
val insetParamSize = paramSize - paddingSize
if (insetParamSize > 0) {
return Dimension(insetParamSize)
}
val insetViewSize = viewSize - paddingSize
if (insetViewSize > 0) {
return Dimension(insetViewSize)
}
return null
}
private inner class LayoutListener(
private val continuation: CancellableContinuation<Size>,
) : OnLayoutChangeListener {
override fun onLayoutChange(
v: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
val size = getSize() ?: return
v.removeOnLayoutChangeListener(this)
continuation.resume(size)
}
}
}

View File

@@ -1,11 +1,15 @@
package org.koitharu.kotatsu.utils.image
import android.graphics.Bitmap
import androidx.core.graphics.get
import androidx.annotation.ColorInt
import androidx.core.graphics.*
import coil.size.Size
import coil.transform.Transformation
import kotlin.math.abs
class TrimTransformation : Transformation {
class TrimTransformation(
private val tolerance: Int = 20,
) : Transformation {
override val cacheKey: String = javaClass.name
@@ -20,7 +24,7 @@ class TrimTransformation : Transformation {
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
if (input[x, y] != prevColor) {
if (!isColorTheSame(input[x, y], prevColor)) {
isColBlank = false
break
}
@@ -39,7 +43,7 @@ class TrimTransformation : Transformation {
var isColBlank = true
val prevColor = input[x, 0]
for (y in 1 until input.height) {
if (input[x, y] != prevColor) {
if (!isColorTheSame(input[x, y], prevColor)) {
isColBlank = false
break
}
@@ -55,7 +59,7 @@ class TrimTransformation : Transformation {
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
if (input[x, y] != prevColor) {
if (!isColorTheSame(input[x, y], prevColor)) {
isRowBlank = false
break
}
@@ -71,7 +75,7 @@ class TrimTransformation : Transformation {
var isRowBlank = true
val prevColor = input[0, y]
for (x in 1 until input.width) {
if (input[x, y] != prevColor) {
if (!isColorTheSame(input[x, y], prevColor)) {
isRowBlank = false
break
}
@@ -93,4 +97,11 @@ class TrimTransformation : Transformation {
override fun equals(other: Any?) = other is TrimTransformation
override fun hashCode() = javaClass.hashCode()
private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean {
return abs(a.red - b.red) <= tolerance &&
abs(a.green - b.green) <= tolerance &&
abs(a.blue - b.blue) <= tolerance &&
abs(a.alpha - b.alpha) <= tolerance
}
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.utils.progress
import androidx.annotation.AnyThread
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.download.ui.service.PausingHandle
class PausingProgressJob<P>(
job: Job,
progress: StateFlow<P>,
private val pausingHandle: PausingHandle,
) : ProgressJob<P>(job, progress) {
@get:AnyThread
val isPaused: Boolean
get() = pausingHandle.isPaused
@AnyThread
suspend fun awaitResumed() = pausingHandle.awaitResumed()
@AnyThread
fun pause() = pausingHandle.pause()
@AnyThread
fun resume() = pausingHandle.resume()
}

View File

@@ -4,7 +4,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
class ProgressJob<P>(
open class ProgressJob<P>(
private val job: Job,
private val progress: StateFlow<P>,
) : Job by job {

View File

@@ -15,12 +15,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.utils.ext.requireBitmap
class RecentListFactory(
private val context: Context,
private val historyRepository: HistoryRepository,
private val coil: ImageLoader
private val coil: ImageLoader,
) : RemoteViewsService.RemoteViewsFactory {
private val dataSet = ArrayList<Manga>()
@@ -29,7 +30,7 @@ class RecentListFactory(
)
private val coverSize = Size(
context.resources.getDimensionPixelSize(R.dimen.widget_cover_width),
context.resources.getDimensionPixelSize(R.dimen.widget_cover_height),
context.resources.getDimensionPixelSize(R.dimen.widget_cover_height)
)
override fun onCreate() = Unit
@@ -39,9 +40,8 @@ class RecentListFactory(
override fun getItemId(position: Int) = dataSet[position].id
override fun onDataSetChanged() {
dataSet.clear()
val data = runBlocking { historyRepository.getList(0, 10) }
dataSet.addAll(data)
dataSet.replaceWith(data)
}
override fun hasStableIds() = true

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppWidgetConfig
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.utils.ext.requireBitmap
class ShelfListFactory(
@@ -32,7 +33,7 @@ class ShelfListFactory(
)
private val coverSize = Size(
context.resources.getDimensionPixelSize(R.dimen.widget_cover_width),
context.resources.getDimensionPixelSize(R.dimen.widget_cover_height),
context.resources.getDimensionPixelSize(R.dimen.widget_cover_height)
)
override fun onCreate() = Unit
@@ -42,7 +43,6 @@ class ShelfListFactory(
override fun getItemId(position: Int) = dataSet[position].id
override fun onDataSetChanged() {
dataSet.clear()
val data = runBlocking {
val category = config.categoryId
if (category == 0L) {
@@ -51,7 +51,7 @@ class ShelfListFactory(
favouritesRepository.getManga(category)
}
}
dataSet.addAll(data)
dataSet.replaceWith(data)
}
override fun hasStableIds() = true
@@ -85,4 +85,4 @@ class ShelfListFactory(
override fun getViewTypeCount() = 1
override fun onDestroy() = Unit
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M12,4C14.1,4 16.1,4.8 17.6,6.3C20.7,9.4 20.7,14.5 17.6,17.6C15.8,19.5 13.3,20.2 10.9,19.9L11.4,17.9C13.1,18.1 14.9,17.5 16.2,16.2C18.5,13.9 18.5,10.1 16.2,7.7C15.1,6.6 13.5,6 12,6V10.6L7,5.6L12,0.6V4M6.3,17.6C3.7,15 3.3,11 5.1,7.9L6.6,9.4C5.5,11.6 5.9,14.4 7.8,16.2C8.3,16.7 8.9,17.1 9.6,17.4L9,19.4C8,19 7.1,18.4 6.3,17.6Z" />
</vector>

View File

@@ -29,6 +29,9 @@
android:layout_height="match_parent"
android:layout_gravity="center_horizontal"
android:background="@null"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/margin_normal"
app:tabGravity="center"
app:tabMode="scrollable" />

View File

@@ -48,7 +48,6 @@
android:gravity="center"
android:text="@string/text_downloads_holder"
android:textAppearance="?attr/textAppearanceBody2"
android:visibility="gone"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -77,10 +77,10 @@
android:id="@+id/textView_percent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBaseline_toBaselineOf="@id/textView_status"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="8dp"
tools:text="25%" />
<TextView
@@ -98,4 +98,30 @@
app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" />
<Button
android:id="@+id/button_cancel"
style="?buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@android:string/cancel"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_details"
tools:visibility="visible" />
<Button
android:id="@+id/button_resume"
style="?buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/try_again"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_cancel"
app:layout_constraintTop_toBottomOf="@id/textView_details"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -12,10 +12,9 @@
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="42dp"
android:layout_height="0dp"
android:layout_height="42dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"

View File

@@ -12,10 +12,9 @@
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="42dp"
android:layout_height="0dp"
android:layout_height="42dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"

View File

@@ -1,320 +1,320 @@
<?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="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>
</resources>

View File

@@ -269,4 +269,55 @@
<string name="percent_string_pattern">%1$s%%</string>
<string name="batch_manga_save_confirm">Är du säker på att du vill ladda ner alla utvalda manga med alla kapitel\? Den här åtgärden kan kräva mycket nätverkstrafik och lagringsutrymme</string>
<string name="parallel_downloads">Parallella nedladdningar</string>
<string name="hide">Dölj</string>
<string name="disable_all">Inaktivera alla</string>
<string name="invalid_domain_message">Ogiltig domän</string>
<string name="dns_over_https">DNS över HTTPS</string>
<string name="detect_reader_mode">Autodetektera läsarläge</string>
<string name="disable_battery_optimization">Inaktivera batterioptimering</string>
<string name="local_manga_processing">Bearbetar nedladdad manga</string>
<string name="detect_reader_mode_summary">Automatiskt detektera om manga är en webtoon</string>
<string name="appwidget_recent_description">Dina nyligen lästa manga</string>
<string name="disable_battery_optimization_summary">Hjälper till med att leta efter uppdateringar i bakgrunden</string>
<string name="new_sources_text">Nya mangakällor finns tillgängliga</string>
<string name="report">Anmäl</string>
<string name="chapters_will_removed_background">Kapitel kommer att tas bort i bakgrunden. Det kan ta lite tid</string>
<string name="tracking">Spårning</string>
<string name="logout">Logga ut</string>
<string name="default_mode">Standardläge</string>
<string name="status_planned">Planerad</string>
<string name="status_reading">Läser</string>
<string name="status_re_reading">Läser om</string>
<string name="status_completed">Läst</string>
<string name="status_on_hold">Pausad</string>
<string name="status_dropped">Övergiven</string>
<string name="use_fingerprint">Använd fingeravtryck om tillgängligt</string>
<string name="appwidget_shelf_description">Manga från dina favoriter</string>
<string name="show_reading_indicators">Visa indikatorer om läsförlopp</string>
<string name="data_deletion">Radering av data</string>
<string name="show_reading_indicators_summary">Visa hur långt du har läst i procent på listor för historik och favoriter</string>
<string name="exclude_nsfw_from_history_summary">Manga markerad som NSFW kommer aldrig läggas till i historiken och din framfart kommer inte att sparas</string>
<string name="clear_cookies_summary">Kan vara till hjälp för att lösa vissa problem. Alla auktoriseringar kommer att ogiltigförklaras</string>
<string name="download_slowdown">Begränsa nedladdningshastighet</string>
<string name="check_new_chapters_title">Leta efter och avisera om nya kapitel</string>
<string name="show_notification_new_chapters_on">Du kommer att få aviseringar om uppdateringar på manga du läser</string>
<string name="show_notification_new_chapters_off">Do kommer inte att få aviseringar men nya kapitel kommer att markeras i listan</string>
<string name="notifications_enable">Aktivera aviseringar</string>
<string name="empty_favourite_categories">Inga favoritkategorier</string>
<string name="name">Namn</string>
<string name="edit">Redigera</string>
<string name="edit_category">Redigera kategori</string>
<string name="show_all">Visa alla</string>
<string name="removed_from_history">Borttaget från historiken</string>
<string name="bookmark_add">Lägg till bokmärke</string>
<string name="bookmark_remove">Ta bort bokmärke</string>
<string name="bookmarks">Bokmärken</string>
<string name="bookmark_removed">Bokmärke borttaget</string>
<string name="bookmark_added">Bokmärke tillagt</string>
<string name="undo">Ångra</string>
<string name="download_slowdown_summary">Hjälper för att undvika att din IP-adress blir blockerad</string>
<string name="select_range">Välj intervall</string>
<string name="not_found_404">Innehållet kunde inte hittas eller har tagits bort</string>
<string name="crash_text">Något gick fel. Skicka en felrapport till utvecklarna för att hjälpa oss att åtgärda problemet.</string>
<string name="send">Skicka</string>
</resources>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="url_github">https://github.com/nv95/Kotatsu</string>
<string name="url_github">https://github.com/KotatsuApp/Kotatsu</string>
<string name="url_discord">https://discord.gg/NNJ5RgVBC5</string>
<string name="url_forpda">https://4pda.to/forum/index.php?showtopic=697669</string>
<string name="url_twitter">https://twitter.com/kotatsuapp</string>

View File

@@ -323,4 +323,8 @@
<string name="invalid_domain_message">Invalid domain</string>
<string name="select_range">Select range</string>
<string name="not_found_404">Content not found or removed</string>
</resources>
<string name="downloading_manga">Downloading manga</string>
<string name="download_summary_pattern" translatable="false">&lt;b>%1$s&lt;/b> %2$s</string>
<string name="reader_control_ltr_summary">Tap on the right edge or pressing the right key always switches to the next page</string>
<string name="reader_control_ltr">Ergonomic reader control</string>
</resources>

View File

@@ -29,6 +29,12 @@
android:title="@string/switch_pages"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="reader_taps_ltr"
android:summary="@string/reader_control_ltr_summary"
android:title="@string/reader_control_ltr" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="reader_animation"

View File

@@ -1,17 +1,14 @@
package org.koitharu.kotatsu.utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield
import org.junit.Assert.assertNull
import org.junit.Test
class CompositeMutexTest {
@Test
fun testSingleLock() = runTest {
fun singleLock() = runTest {
val mutex = CompositeMutex<Int>()
mutex.lock(1)
mutex.lock(2)
@@ -22,7 +19,7 @@ class CompositeMutexTest {
}
@Test
fun testDoubleLock() = runTest {
fun doubleLock() = runTest {
val mutex = CompositeMutex<Int>()
repeat(2) {
launch(Dispatchers.Default) {
@@ -36,4 +33,20 @@ class CompositeMutexTest {
}
assertNull(tryLock)
}
@Test
fun cancellation() = runTest {
val mutex = CompositeMutex<Int>()
mutex.lock(1)
val job = launch {
try {
mutex.lock(1)
} finally {
mutex.unlock(1)
}
}
withTimeout(2000) {
job.cancelAndJoin()
}
}
}