Merge branch 'master' into devel

This commit is contained in:
Koitharu
2022-08-11 16:22:35 +03:00
15 changed files with 811 additions and 699 deletions

View File

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

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@@ -12,8 +13,10 @@ import java.io.File
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException import okio.IOException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
@@ -63,102 +66,112 @@ class DownloadManager @AssistedInject constructor(
DownloadState.Queued(startId = startId, manga = manga, cover = null), DownloadState.Queued(startId = startId, manga = manga, cover = null),
) )
val pausingHandle = PausingHandle() val pausingHandle = PausingHandle()
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId) 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) return PausingProgressJob(job, stateFlow, pausingHandle)
} }
private fun downloadMangaImpl( private suspend fun downloadMangaImpl(
manga: Manga, manga: Manga,
chaptersIds: LongArray?, chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>, outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle, pausingHandle: PausingHandle,
startId: Int, startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { ) {
@Suppress("NAME_SHADOWING") @Suppress("NAME_SHADOWING")
var manga = manga var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet() val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga) val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover) outState.value = DownloadState.Queued(startId, manga, cover)
localMangaRepository.lockManga(manga.id) withMangaLock(manga) {
semaphore.acquire() semaphore.withPermit {
coroutineContext[WakeLockNode]?.acquire() outState.value = DownloadState.Preparing(startId, manga, null)
outState.value = DownloadState.Preparing(startId, manga, null) val destination = localMangaRepository.getOutputDir()
val destination = localMangaRepository.getOutputDir() checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } val tempFileName = "${manga.id}_$startId.tmp"
val tempFileName = "${manga.id}_$startId.tmp" var output: CbzMangaOutput? = null
var output: CbzMangaOutput? = null try {
try { if (manga.source == MangaSource.LOCAL) {
if (manga.source == MangaSource.LOCAL) { manga = localMangaRepository.getRemoteManga(manga)
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance") ?: error("Cannot obtain remote manga instance")
}
val repo = mangaRepositoryFactory.create(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( val repo = mangaRepositoryFactory.create(manga.source)
startId = startId, outState.value = DownloadState.Preparing(startId, manga, cover)
manga = data, val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
cover = cover, output = CbzMangaOutput.get(destination, data)
totalChapters = chapters.size, val coverUrl = data.largeCoverUrl ?: data.coverUrl
currentChapter = chapterIndex, downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
totalPages = pages.size, output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
currentPage = pageIndex, }
) 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,
)
if (settings.isDownloadsSlowdownEnabled) { if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY) 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, false)
} finally {
withContext(NonCancellable) {
output?.cleanup()
File(destination, tempFileName).deleteAwait()
coroutineContext[WakeLockNode]?.release()
semaphore.release()
localMangaRepository.unlockManga(manga.id)
}
} }
} }
@@ -207,6 +220,7 @@ class DownloadManager @AssistedInject constructor(
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable -> CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
val prevValue = outState.value val prevValue = outState.value
outState.value = DownloadState.Error( outState.value = DownloadState.Error(
startId = prevValue.startId, startId = prevValue.startId,
@@ -223,10 +237,18 @@ class DownloadManager @AssistedInject constructor(
.data(manga.coverUrl) .data(manga.coverUrl)
.referer(manga.publicUrl) .referer(manga.publicUrl)
.size(coverWidth, coverHeight) .size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build(), .build(),
).drawable ).drawable
}.getOrNull() }.getOrNull()
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {

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,25 +1,27 @@
package org.koitharu.kotatsu.download.ui package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.bindServiceWithLifecycle
@AndroidEntryPoint @AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() { class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@@ -36,26 +38,61 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
bindServiceWithLifecycle( val connection = DownloadServiceConnection(adapter)
owner = this, bindService(Intent(this, DownloadService::class.java), connection, 0)
service = Intent(this, DownloadService::class.java), lifecycle.addObserver(connection)
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)
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding( binding.recyclerView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
binding.toolbar.updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right,
) )
binding.recyclerView.updatePadding( }
bottom = insets.bottom,
) 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 { companion object {

View File

@@ -7,143 +7,299 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.text.format.DateUtils import android.text.format.DateUtils
import android.util.SparseArray
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap 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 com.google.android.material.R as materialR
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.utils.PendingIntentCompat import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DownloadNotification(private val context: Context, startId: Int) { class DownloadNotification(private val context: Context) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID) private val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val cancelAction = NotificationCompat.Action( private val states = SparseArray<DownloadState>()
materialR.drawable.material_ic_clear_black_24dp, private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
context.getString(android.R.string.cancel),
PendingIntent.getBroadcast( private val queueIntent = PendingIntent.getActivity(
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,
),
)
private val listIntent = PendingIntent.getActivity(
context, context,
REQUEST_LIST, REQUEST_QUEUE,
DownloadsActivity.newIntent(context), DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE, PendingIntentCompat.FLAG_IMMUTABLE,
) )
private val localListIntent = PendingIntent.getActivity(
context,
REQUEST_LIST_LOCAL,
MangaListActivity.newIntent(context, MangaSource.LOCAL),
PendingIntentCompat.FLAG_IMMUTABLE,
)
init { init {
builder.setOnlyAlertOnce(true) groupBuilder.setOnlyAlertOnce(true)
builder.setDefaults(0) groupBuilder.setDefaults(0)
builder.color = ContextCompat.getColor(context, R.color.blue_primary) groupBuilder.color = ContextCompat.getColor(context, R.color.blue_primary)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE groupBuilder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true) groupBuilder.setSilent(true)
groupBuilder.setGroup(GROUP_ID)
groupBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
groupBuilder.setGroupSummary(true)
groupBuilder.setContentTitle(context.getString(R.string.downloading_manga))
} }
fun create(state: DownloadState, timeLeft: Long): Notification { fun buildGroupNotification(): Notification {
builder.setContentTitle(state.manga.title) val style = NotificationCompat.InboxStyle(groupBuilder)
builder.setContentText(context.getString(R.string.manga_downloading_)) var progress = 0f
builder.setProgress(1, 0, true) var isAllDone = true
builder.setSmallIcon(android.R.drawable.stat_sys_download) var isInProgress = false
builder.setContentIntent(listIntent) groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
builder.setStyle(null) states.forEach { _, state ->
builder.setLargeIcon(state.cover?.toBitmap())
builder.clearActions()
builder.setVisibility(
if (state.manga.isNsfw) { if (state.manga.isNsfw) {
NotificationCompat.VISIBILITY_PRIVATE groupBuilder.setVisibility(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)
} }
is DownloadState.Done -> { val summary = when (state) {
builder.setProgress(0, 0, false) is DownloadState.Cancelled -> {
builder.setContentText(context.getString(R.string.download_complete)) progress++
builder.setContentIntent(createMangaIntent(context, state.localManga)) context.getString(R.string.cancelling_)
builder.setAutoCancel(true) }
builder.setSmallIcon(android.R.drawable.stat_sys_download_done) is DownloadState.Done -> {
builder.setCategory(null) progress++
builder.setStyle(null) context.getString(R.string.download_complete)
builder.setOngoing(false) }
} is DownloadState.Error -> {
is DownloadState.Error -> { isAllDone = false
val message = state.error.getDisplayMessage(context.resources) context.getString(R.string.error)
builder.setProgress(0, 0, false) }
builder.setSmallIcon(android.R.drawable.stat_notify_error) is DownloadState.PostProcessing -> {
builder.setSubText(context.getString(R.string.error)) progress++
builder.setContentText(message) isInProgress = true
builder.setAutoCancel(!state.canRetry) isAllDone = false
builder.setOngoing(state.canRetry) context.getString(R.string.processing_)
builder.setCategory(NotificationCompat.CATEGORY_ERROR) }
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) is DownloadState.Preparing -> {
if (state.canRetry) { isAllDone = false
builder.addAction(cancelAction) isInProgress = true
builder.addAction(retryAction) 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)
} }
} }
is DownloadState.PostProcessing -> { style.addLine(
builder.setProgress(1, 0, true) context.getString(
builder.setContentText(context.getString(R.string.processing_)) R.string.download_summary_pattern,
builder.setStyle(null) state.manga.title.ellipsize(16).htmlEncode(),
builder.setOngoing(true) summary.htmlEncode(),
} ).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY),
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))
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
} }
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.setContentIntent(if (isAllDone) localListIntent else queueIntent)
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(queueIntent)
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( private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
@@ -155,8 +311,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
companion object { companion object {
private const val TAG = "download"
private const val CHANNEL_ID = "download" private const val CHANNEL_ID = "download"
private const val REQUEST_LIST = 6 private const val GROUP_ID = "downloads"
private const val REQUEST_QUEUE = 6
private const val REQUEST_LIST_LOCAL = 7
const val ID_GROUP = 9999
private const val ID_GROUP_DETACHED = 9998
fun createChannel(context: Context) { fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@@ -8,6 +8,8 @@ import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import android.widget.Toast import android.widget.Toast
import androidx.annotation.MainThread
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
@@ -19,14 +21,12 @@ import javax.inject.Inject
import kotlin.collections.set import kotlin.collections.set
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState 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.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.throttle import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
@@ -37,7 +37,8 @@ import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
class DownloadService : BaseService() { class DownloadService : BaseService() {
private lateinit var downloadManager: DownloadManager private lateinit var downloadManager: DownloadManager
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher private lateinit var downloadNotification: DownloadNotification
private lateinit var wakeLock: PowerManager.WakeLock
@Inject @Inject
lateinit var downloadManagerFactory: DownloadManager.Factory lateinit var downloadManagerFactory: DownloadManager.Factory
@@ -49,13 +50,13 @@ class DownloadService : BaseService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
isRunning = true isRunning = true
notificationSwitcher = ForegroundNotificationSwitcher(this) downloadNotification = DownloadNotification(this)
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = downloadManagerFactory.create( downloadManager = downloadManagerFactory.create(lifecycleScope)
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), wakeLock.acquire(TimeUnit.HOURS.toMillis(8))
)
DownloadNotification.createChannel(this) DownloadNotification.createChannel(this)
startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())
val intentFilter = IntentFilter() val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL) intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME) intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
@@ -71,7 +72,7 @@ class DownloadService : BaseService() {
jobCount.value = jobs.size jobCount.value = jobs.size
START_REDELIVER_INTENT START_REDELIVER_INTENT
} else { } else {
stopSelf(startId) stopSelfIfIdle()
START_NOT_STICKY START_NOT_STICKY
} }
} }
@@ -83,6 +84,7 @@ class DownloadService : BaseService() {
override fun onDestroy() { override fun onDestroy() {
unregisterReceiver(controlReceiver) unregisterReceiver(controlReceiver)
wakeLock.release()
isRunning = false isRunning = false
super.onDestroy() super.onDestroy()
} }
@@ -100,10 +102,10 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) { private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch { lifecycleScope.launch {
val startId = job.progressValue.startId val startId = job.progressValue.startId
val notification = DownloadNotification(this@DownloadService, startId) val notificationItem = downloadNotification.newItem(startId)
try { try {
val timeLeftEstimator = TimeLeftEstimator() val timeLeftEstimator = TimeLeftEstimator()
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L)) notificationItem.notify(job.progressValue, -1L)
job.progressAsFlow() job.progressAsFlow()
.onEach { state -> .onEach { state ->
if (state is DownloadState.Progress) { if (state is DownloadState.Progress) {
@@ -116,7 +118,7 @@ class DownloadService : BaseService() {
.whileActive() .whileActive()
.collect { state -> .collect { state ->
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft() val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationSwitcher.notify(startId, notification.create(state, timeLeft)) notificationItem.notify(state, timeLeft)
} }
job.join() job.join()
} finally { } finally {
@@ -126,18 +128,17 @@ class DownloadService : BaseService() {
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)), .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)),
) )
} }
notificationSwitcher.detach( if (job.isCancelled) {
startId, notificationItem.dismiss()
if (job.isCancelled) { if (jobs.remove(startId) != null) {
null jobCount.value = jobs.size
} else { }
notification.create(job.progressValue, -1L) } else {
}, notificationItem.notify(job.progressValue, -1L)
) }
jobs.remove(job.progressValue.startId)
jobCount.value = jobs.size
stopSelf(startId)
} }
}.invokeOnCompletion {
stopSelfIfIdle()
} }
} }
@@ -149,6 +150,16 @@ class DownloadService : BaseService() {
private val DownloadState.isTerminal: Boolean private val DownloadState.isTerminal: Boolean
get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry) 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() { inner class ControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
@@ -167,12 +178,12 @@ class DownloadService : BaseService() {
class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver { class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver {
private var downloadsStateFlow = MutableStateFlow<Collection<PausingProgressJob<DownloadState>>>(emptyList()) private var downloadsStateFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
init { init {
service.lifecycle.addObserver(this) service.lifecycle.addObserver(this)
service.jobCount.onEach { service.jobCount.onEach {
downloadsStateFlow.value = service.jobs.values downloadsStateFlow.value = service.jobs.values.toList()
}.launchIn(service.lifecycleScope) }.launchIn(service.lifecycleScope)
} }

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

@@ -275,7 +275,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
locks.lock(id) locks.lock(id)
} }
suspend fun unlockManga(id: Long) { fun unlockManga(id: Long) {
locks.unlock(id) locks.unlock(id)
} }

View File

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

View File

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

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

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

View File

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

View File

@@ -360,6 +360,8 @@
<string name="removed_from_s">Removed from \"%s\"</string> <string name="removed_from_s">Removed from \"%s\"</string>
<string name="options">Options</string> <string name="options">Options</string>
<string name="not_found_404">Content not found or removed</string> <string name="not_found_404">Content not found or removed</string>
<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="incognito_mode">Incognito mode</string> <string name="incognito_mode">Incognito mode</string>
<string name="app_update_available_s">Application update available: %s</string> <string name="app_update_available_s">Application update available: %s</string>
<string name="no_chapters">No chapters</string> <string name="no_chapters">No chapters</string>

View File

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