diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e064f604c..22d6ecda5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -135,6 +135,7 @@
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
index e2802c6c6..6c4f025a0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.request.ImageRequest
+import coil.size.Scale
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -12,8 +13,10 @@ 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
@@ -63,102 +66,112 @@ class DownloadManager @AssistedInject constructor(
DownloadState.Queued(startId = startId, manga = manga, cover = null),
)
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)
}
- private fun downloadMangaImpl(
+ private suspend fun downloadMangaImpl(
manga: Manga,
chaptersIds: LongArray?,
outState: MutableStateFlow,
pausingHandle: PausingHandle,
startId: Int,
- ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
+ ) {
@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 = 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),
- )
+ 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")
}
- outState.value = DownloadState.Progress(
- startId = startId,
- manga = data,
- cover = cover,
- totalChapters = chapters.size,
- currentChapter = chapterIndex,
- totalPages = pages.size,
- currentPage = pageIndex,
- )
+ 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(
+ startId = startId,
+ manga = data,
+ cover = cover,
+ totalChapters = chapters.size,
+ currentChapter = chapterIndex,
+ totalPages = pages.size,
+ currentPage = pageIndex,
+ )
- if (settings.isDownloadsSlowdownEnabled) {
- delay(SLOWDOWN_DELAY)
+ 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, 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) =
CoroutineExceptionHandler { _, throwable ->
+ throwable.printStackTraceDebug()
val prevValue = outState.value
outState.value = DownloadState.Error(
startId = prevValue.startId,
@@ -223,10 +237,18 @@ class DownloadManager @AssistedInject constructor(
.data(manga.coverUrl)
.referer(manga.publicUrl)
.size(coverWidth, coverHeight)
+ .scale(Scale.FILL)
.build(),
).drawable
}.getOrNull()
+ private suspend inline fun withMangaLock(manga: Manga, block: () -> T) = try {
+ localMangaRepository.lockManga(manga.id)
+ block()
+ } finally {
+ localMangaRepository.unlockManga(manga.id)
+ }
+
@AssistedFactory
interface Factory {
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt
deleted file mode 100644
index 8bbfc2f2d..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt
+++ /dev/null
@@ -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
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
index f5e1c4996..e029fa81e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
@@ -1,25 +1,27 @@
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 coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
-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.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService
-import org.koitharu.kotatsu.utils.bindServiceWithLifecycle
@AndroidEntryPoint
class DownloadsActivity : BaseActivity() {
@@ -36,26 +38,61 @@ class DownloadsActivity : BaseActivity() {
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
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.root.updatePadding(
+ binding.recyclerView.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ bottom = insets.bottom,
+ )
+ binding.toolbar.updatePadding(
left = insets.left,
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?) {
+ adapter.items = items?.toList().orEmpty()
+ binding.textViewHolder.isVisible = items.isNullOrEmpty()
+ }
}
companion object {
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
index b5f4d90d3..6ff17d38d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
@@ -7,143 +7,299 @@ 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.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.ellipsize
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.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 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,
- ),
- )
- private val listIntent = PendingIntent.getActivity(
+ private val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ private val states = SparseArray()
+ private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
+
+ private val queueIntent = PendingIntent.getActivity(
context,
- REQUEST_LIST,
+ REQUEST_QUEUE,
DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE,
)
+ private val localListIntent = PendingIntent.getActivity(
+ context,
+ REQUEST_LIST_LOCAL,
+ MangaListActivity.newIntent(context, MangaSource.LOCAL),
+ 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)
+ 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.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
- },
- )
- 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)
+ groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
}
- 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(!state.canRetry)
- builder.setOngoing(state.canRetry)
- builder.setCategory(NotificationCompat.CATEGORY_ERROR)
- builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
- if (state.canRetry) {
- builder.addAction(cancelAction)
- builder.addAction(retryAction)
+ 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)
}
}
- 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))
- }
- builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
- 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.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(
@@ -155,8 +311,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
companion object {
+ private const val TAG = "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) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
index 9b10346fd..0328ccab1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
@@ -8,6 +8,8 @@ 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
@@ -19,14 +21,12 @@ import javax.inject.Inject
import kotlin.collections.set
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
-import kotlinx.coroutines.plus
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
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.throttle
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
@@ -37,7 +37,8 @@ import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
class DownloadService : BaseService() {
private lateinit var downloadManager: DownloadManager
- private lateinit var notificationSwitcher: ForegroundNotificationSwitcher
+ private lateinit var downloadNotification: DownloadNotification
+ private lateinit var wakeLock: PowerManager.WakeLock
@Inject
lateinit var downloadManagerFactory: DownloadManager.Factory
@@ -49,13 +50,13 @@ class DownloadService : BaseService() {
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 = downloadManagerFactory.create(
- coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
- )
+ downloadManager = downloadManagerFactory.create(lifecycleScope)
+ wakeLock.acquire(TimeUnit.HOURS.toMillis(8))
DownloadNotification.createChannel(this)
+ startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())
val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
@@ -71,7 +72,7 @@ class DownloadService : BaseService() {
jobCount.value = jobs.size
START_REDELIVER_INTENT
} else {
- stopSelf(startId)
+ stopSelfIfIdle()
START_NOT_STICKY
}
}
@@ -83,6 +84,7 @@ class DownloadService : BaseService() {
override fun onDestroy() {
unregisterReceiver(controlReceiver)
+ wakeLock.release()
isRunning = false
super.onDestroy()
}
@@ -100,10 +102,10 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob) {
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) {
@@ -116,7 +118,7 @@ class DownloadService : BaseService() {
.whileActive()
.collect { state ->
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
- notificationSwitcher.notify(startId, notification.create(state, timeLeft))
+ notificationItem.notify(state, timeLeft)
}
job.join()
} finally {
@@ -126,18 +128,17 @@ class DownloadService : BaseService() {
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)),
)
}
- notificationSwitcher.detach(
- startId,
- if (job.isCancelled) {
- null
- } else {
- notification.create(job.progressValue, -1L)
- },
- )
- jobs.remove(job.progressValue.startId)
- jobCount.value = jobs.size
- stopSelf(startId)
+ if (job.isCancelled) {
+ notificationItem.dismiss()
+ if (jobs.remove(startId) != null) {
+ jobCount.value = jobs.size
+ }
+ } else {
+ notificationItem.notify(job.progressValue, -1L)
+ }
}
+ }.invokeOnCompletion {
+ stopSelfIfIdle()
}
}
@@ -149,6 +150,16 @@ class DownloadService : BaseService() {
private val DownloadState.isTerminal: Boolean
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() {
override fun onReceive(context: Context, intent: Intent?) {
@@ -167,12 +178,12 @@ class DownloadService : BaseService() {
class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver {
- private var downloadsStateFlow = MutableStateFlow>>(emptyList())
+ private var downloadsStateFlow = MutableStateFlow>>(emptyList())
init {
service.lifecycle.addObserver(this)
service.jobCount.onEach {
- downloadsStateFlow.value = service.jobs.values
+ downloadsStateFlow.value = service.jobs.values.toList()
}.launchIn(service.lifecycleScope)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt
deleted file mode 100644
index 679405295..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt
+++ /dev/null
@@ -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()
- 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)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
index 1b79daf25..65e0b5658 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
@@ -275,7 +275,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
locks.lock(id)
}
- suspend fun unlockManga(id: Long) {
+ fun unlockManga(id: Long) {
locks.unlock(id)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt
index 052aba070..53e1af007 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt
@@ -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
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt
index 7f9e2278d..5f4f12fda 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt
@@ -1,14 +1,14 @@
package org.koitharu.kotatsu.utils
import android.util.ArrayMap
+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 : Set {
@@ -35,7 +35,7 @@ class CompositeMutex : Set {
}
suspend fun lock(element: T) {
- while (currentCoroutineContext().isActive) {
+ while (coroutineContext.isActive) {
waitForRemoval(element)
mutex.withLock {
if (data[element] == null) {
@@ -46,11 +46,9 @@ class CompositeMutex : Set {
}
}
- 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) {
@@ -68,4 +66,4 @@ class CompositeMutex : Set {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt b/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt
deleted file mode 100644
index cedc875fa..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt
+++ /dev/null
@@ -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(null)
-
- val service: StateFlow
- 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
-}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_downloads.xml b/app/src/main/res/layout/activity_downloads.xml
index 9661f7c38..7d5af0466 100644
--- a/app/src/main/res/layout/activity_downloads.xml
+++ b/app/src/main/res/layout/activity_downloads.xml
@@ -49,7 +49,6 @@
android:gravity="center"
android:text="@string/text_downloads_holder"
android:textAppearance="?attr/textAppearanceBody2"
- android:visibility="gone"
tools:visibility="visible" />
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 07ab4f6bb..8b053e90f 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -1,320 +1,320 @@
- Закрыть меню
- Открыть меню
- На устройстве
- Избранное
- История
- Произошла ошибка
- Не удалось подключиться к интернету
- Подробности
- Главы
- Список
- Подробный список
- Таблица
- Вид списка
- Настройки
- Онлайн каталоги
- Загрузка…
- Глава %1$d из %2$d
- Закрыть
- Повторить
- Очистить историю
- Ничего не найдено
- Истории пока нет
- Читать
- Избранного пока нет
- В избранное
- Новая категория
- Добавить
- Введите название
- Сохранить
- Поделиться
- Создать ярлык…
- Поделиться %s
- Поиск
- Поиск манги
- Загрузка…
- Обработка…
- Загружено
- Загрузки
- Имя
- Популярная
- Обновлённая
- Новая
- Рейтинг
- Порядок сортировки
- Фильтр
- Тема
- Светлая
- Тёмная
- Как в системе
- Страницы
- Очистить
- Очистить всю историю чтения полностью\?
- Удалить
- «%s» удалено из истории
- «%s» удалено с устройства
- Дождитесь завершения загрузки…
- Сохранить страницу
- Сохранено
- Поделиться изображением
- Импорт
- Удалить
- Операция не поддерживается
- Выберите CBZ-файл или ZIP
- Нет описания
- История и кэш
- Очистить кэш страниц
- Кэш
- Б|кБ|МБ|ГБ|ТБ
- Стандартный
- Манхва
- Режим чтения
- Размер таблицы
- Поиск по %s
- Удалить мангу
- Удалить \"%s\" с устройства навсегда\?
- Настройки чтения
- Листание страниц
- Нажатия по краям
- Кнопки громкости
- Продолжить
- Предупреждение
- Это может привести к расходу большого количества трафика
- Больше не спрашивать
- Отмена…
- Ошибка
- Очистить кэш миниатюр
- Очистить историю поиска
- Очищено
- Только жесты
- Внутренний накопитель
- Внешнее хранилище
- Домен
- Проверять наличие новых версий приложения
- Доступна новая версия приложения
- Показывать уведомление, если доступна новая версия
- Открыть в веб-браузере
- В этой манге %s. Сохранить их все\?
- Сохранить
- Уведомления
- Включено %1$d из %2$d
- Новые главы
- Загрузить
- Читать с начала
- Перезапустить
- Настройки уведомлений
- Звук уведомления
- Светодиодная индикация
- Вибросигнал
- Категории избранного
- Категории…
- Переименовать
- Удалить категорию \"%s\" из избранного\?
+ Закрыть меню
+ Открыть меню
+ На устройстве
+ Избранное
+ История
+ Произошла ошибка
+ Не удалось подключиться к интернету
+ Подробности
+ Главы
+ Список
+ Подробный список
+ Таблица
+ Вид списка
+ Настройки
+ Онлайн каталоги
+ Загрузка…
+ Глава %1$d из %2$d
+ Закрыть
+ Повторить
+ Очистить историю
+ Ничего не найдено
+ Истории пока нет
+ Читать
+ Избранного пока нет
+ В избранное
+ Новая категория
+ Добавить
+ Введите название
+ Сохранить
+ Поделиться
+ Создать ярлык…
+ Поделиться %s
+ Поиск
+ Поиск манги
+ Загрузка…
+ Обработка…
+ Загружено
+ Загрузки
+ Имя
+ Популярная
+ Обновлённая
+ Новая
+ Рейтинг
+ Порядок сортировки
+ Фильтр
+ Тема
+ Светлая
+ Тёмная
+ Как в системе
+ Страницы
+ Очистить
+ Очистить всю историю чтения полностью\?
+ Удалить
+ «%s» удалено из истории
+ «%s» удалено с устройства
+ Дождитесь завершения загрузки…
+ Сохранить страницу
+ Сохранено
+ Поделиться изображением
+ Импорт
+ Удалить
+ Операция не поддерживается
+ Выберите CBZ-файл или ZIP
+ Нет описания
+ История и кэш
+ Очистить кэш страниц
+ Кэш
+ Б|кБ|МБ|ГБ|ТБ
+ Стандартный
+ Манхва
+ Режим чтения
+ Размер таблицы
+ Поиск по %s
+ Удалить мангу
+ Удалить \"%s\" с устройства навсегда\?
+ Настройки чтения
+ Листание страниц
+ Нажатия по краям
+ Кнопки громкости
+ Продолжить
+ Предупреждение
+ Это может привести к расходу большого количества трафика
+ Больше не спрашивать
+ Отмена…
+ Ошибка
+ Очистить кэш миниатюр
+ Очистить историю поиска
+ Очищено
+ Только жесты
+ Внутренний накопитель
+ Внешнее хранилище
+ Домен
+ Проверять наличие новых версий приложения
+ Доступна новая версия приложения
+ Показывать уведомление, если доступна новая версия
+ Открыть в веб-браузере
+ В этой манге %s. Сохранить их все\?
+ Сохранить
+ Уведомления
+ Включено %1$d из %2$d
+ Новые главы
+ Загрузить
+ Читать с начала
+ Перезапустить
+ Настройки уведомлений
+ Звук уведомления
+ Светодиодная индикация
+ Вибросигнал
+ Категории избранного
+ Категории…
+ Переименовать
+ Удалить категорию \"%s\" из избранного\?
\nВся манга в ней будет потеряна.
- Удалить
- Как-то здесь пусто…
- Попробуйте переформулировать запрос.
- Вы можете использовать категории для организации своих избранных. Нажмите «+», чтобы создать категорию
- То, что вы прочитаете, будет отображено здесь
- Найдите, что почитать, в боковом меню.
- Сохраните что-нибудь
- Сохраните что-нибудь из онлайн-каталога или импортируйте из файла.
- Полка
- Недавнее
- Анимация листания
- Папка для загрузок
- Недоступно
- Нет доступного хранилища
- Другое хранилище
- Готово
- Всё избранное
- Категория пуста
- Прочитать позже
- Обновления
- Новые главы из того, что вы читаете, будут показаны здесь
- Результаты поиска
- Похожие
- Новая версия: %s
- Размер: %s
- Ожидание подключения…
- Очистить ленту обновлений
- Очищено
- Повернуть экран
- Обновить
- Обновление скоро начнётся
- Следить за обновлениями
- Не проверять
- Введите пароль
- Неверный пароль
- Защитить приложение
- Запрашивать пароль при запуске Kotatsu
- Повторите пароль
- Пароли не совпадают
- О программе
- Версия %s
- Проверить обновления
- Проверка обновления…
- Не удалось проверить обновления
- Нет доступных обновлений
- Справа налево (←)
- Создать категорию
- Масштабирование
- Вписать в экран
- Подогнать по высоте
- Подогнать по ширине
- Исходный размер
- Чёрная
- Потребляет меньше энергии на экранах AMOLED
- Резервное копирование и восстановление
- Создать резервную копию
- Восстановить данные
- Восстановлено
- Подготовка…
- Файл не найден
- Все данные были восстановлены
- Данные были восстановлены, но возникли некоторые ошибки
- Вы можете создать резервную копию избранного и истории и потом восстановить их
- Только что
- Вчера
- Давно
- Группировать
- Сегодня
- Попробовать ещё раз
- Выбранный режим будет сохранён для текущей манги
- Без звука
- Необходимо пройти CAPTCHA
- Пройти
- Очистить куки
- Все файлы cookie были удалены
- Проверка новых глав: %1$d из %2$d
- Очистить ленту
- Удалить всю историю обновлений навсегда\?
- Проверка новых глав
- В обратном порядке
- Войти
- Авторизуйтесь, чтобы просмотреть этот контент
- По умолчанию: %s
- …и ещё %1$d
- Далее
- Введите пароль для запуска приложения
- Подтвердить
- Пароль должен состоять из 4 символов или более
- Поиск только по %s
- Другие
- Добро пожаловать
- Удалить все последние поисковые запросы навсегда\?
- Резервная копия сохранена
- Некоторые устройства имеют различное поведение системы, что может привести к нарушению фоновых задач.
- Подробнее
- В очереди
- Нет активных загрузок
- Глава отсутствует
- Скачайте или прочитайте эту недостающую главу онлайн.
- Помочь с переводом приложения
- Перевод
- Тема на 4PDA
- Обратная связь
- Авторизация выполнена
- Вход в %s не поддерживается
- Вы выйдете из всех источников
- Жанры
- Завершено
- Онгоинг
- Формат даты
- По умолчанию
- Исключить NSFW мангу из истории
- Вы должны ввести имя
- Показывать номера страницы
- Включенные источники
- Доступные источники
- Динамическая тема
- Применяет тему приложения, основанную на цветовой палитре обоев на устройстве
- Политика скриншотов
- Разрешить
- Запретить для NSFW
- Всегда блокировать
- Рекомендации
- Включить рекомендации
- Предлагать мангу на основе Ваших предпочтений
- Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы
- Начните читать мангу, чтобы получать персональные предложения
- Не предлагать NSFW мангу
- Включено
- Выключено
- Не удалось загрузить список жанров
- Вычисление…
- Создать проблему на GitHub
- Импорт манги: %1$d из %2$d
- Сбросить фильтр
- Поиск по жанрам
- Выберите языки, на которых Вы хоите читать мангу. Это можно будет изменить позже в настройках.
- Никогда
- Только по Wi-Fi
- Всегда
- Предварительная загрузка страниц
- Вы авторизованы как %s
- 18+
- Разные языки
- Найти главу
- В этой манге нет глав
- Оформление
- Контент
- Обновление рекомендаций
- Исключить жанры
- Укажите жанры, которые Вы не хотите видеть в рекомендациях
- Удалить выбранную мангу с накопителя?
- Удаление завершено
- Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе
- Загружать параллельно
- Замедление загрузки
- Помогает избежать блокировки IP-адреса
- Обработка сохранённой манги
- Главы будут удалены в фоновом режиме. Это может занять какое-то время
- Скрыть
- Доступны новые источники манги
- Проверять новые главы и уведомлять о них
- Вы будете получать уведомления об обновлении манги, которую Вы читаете
- Вы не будете получать уведомления, но новые главы будут отображаться в списке
- Включить уведомления
- Название
- Изменить
- Изменить категорию
- Отслеживание
- Нет категорий избранного
- Добавить закладку
- Удалить закладку
- Закладки
- Закладка удалена
- Закладка добавлена
- Отменить
- Удалено из истории
- DNS через HTTPS
- Режим по умолчанию
- Автоопределение режима чтения
- Автоматически определяет, является ли манга веб-комиксом
- Отключить оптимизацию батареи
- Помогает с фоновой проверкой обновлений
- Что-то пошло не так. Пожалуйста, отправьте отчёт разработчикам, чтобы помочь всё исправить
- Отправить
- Отключить все
- Использовать отпечаток пальца, если доступно
- Манга из Вашего избранного
- Манга, которую Вы недавно читали
- Читаю
- Запланировано
- Отложено
- Заброшено
- Завершено
- Показать процент прочитанного в истории и избранном
- Манга, помеченная как NSFW, никогда не будет добавлена в историю и ваш прогресс чтения не будет сохранен
- %1$s%%
- Отчёт
- Выйти
- Перечитываю
- Показать индикаторы прогресса чтения
- Удаление данных
- Может помочь в случае каких-либо проблем. Все авторизации будут аннулированы
- Показать все
+ Удалить
+ Как-то здесь пусто…
+ Попробуйте переформулировать запрос.
+ Вы можете использовать категории для организации своих избранных. Нажмите «+», чтобы создать категорию
+ То, что вы прочитаете, будет отображено здесь
+ Найдите, что почитать, в боковом меню.
+ Сохраните что-нибудь
+ Сохраните что-нибудь из онлайн-каталога или импортируйте из файла.
+ Полка
+ Недавнее
+ Анимация листания
+ Папка для загрузок
+ Недоступно
+ Нет доступного хранилища
+ Другое хранилище
+ Готово
+ Всё избранное
+ Категория пуста
+ Прочитать позже
+ Обновления
+ Новые главы из того, что вы читаете, будут показаны здесь
+ Результаты поиска
+ Похожие
+ Новая версия: %s
+ Размер: %s
+ Ожидание подключения…
+ Очистить ленту обновлений
+ Очищено
+ Повернуть экран
+ Обновить
+ Обновление скоро начнётся
+ Следить за обновлениями
+ Не проверять
+ Введите пароль
+ Неверный пароль
+ Защитить приложение
+ Запрашивать пароль при запуске Kotatsu
+ Повторите пароль
+ Пароли не совпадают
+ О программе
+ Версия %s
+ Проверить обновления
+ Проверка обновления…
+ Не удалось проверить обновления
+ Нет доступных обновлений
+ Справа налево (←)
+ Создать категорию
+ Масштабирование
+ Вписать в экран
+ Подогнать по высоте
+ Подогнать по ширине
+ Исходный размер
+ Чёрная
+ Потребляет меньше энергии на экранах AMOLED
+ Резервное копирование и восстановление
+ Создать резервную копию
+ Восстановить данные
+ Восстановлено
+ Подготовка…
+ Файл не найден
+ Все данные были восстановлены
+ Данные были восстановлены, но возникли некоторые ошибки
+ Вы можете создать резервную копию избранного и истории и потом восстановить их
+ Только что
+ Вчера
+ Давно
+ Группировать
+ Сегодня
+ Попробовать ещё раз
+ Выбранный режим будет сохранён для текущей манги
+ Без звука
+ Необходимо пройти CAPTCHA
+ Пройти
+ Очистить куки
+ Все файлы cookie были удалены
+ Проверка новых глав: %1$d из %2$d
+ Очистить ленту
+ Удалить всю историю обновлений навсегда\?
+ Проверка новых глав
+ В обратном порядке
+ Войти
+ Авторизуйтесь, чтобы просмотреть этот контент
+ По умолчанию: %s
+ …и ещё %1$d
+ Далее
+ Введите пароль для запуска приложения
+ Подтвердить
+ Пароль должен состоять из 4 символов или более
+ Поиск только по %s
+ Другие
+ Добро пожаловать
+ Удалить все последние поисковые запросы навсегда\?
+ Резервная копия сохранена
+ Некоторые устройства имеют различное поведение системы, что может привести к нарушению фоновых задач.
+ Подробнее
+ В очереди
+ Нет активных загрузок
+ Глава отсутствует
+ Скачайте или прочитайте эту недостающую главу онлайн.
+ Помочь с переводом приложения
+ Перевод
+ Тема на 4PDA
+ Обратная связь
+ Авторизация выполнена
+ Вход в %s не поддерживается
+ Вы выйдете из всех источников
+ Жанры
+ Завершено
+ Онгоинг
+ Формат даты
+ По умолчанию
+ Исключить NSFW мангу из истории
+ Вы должны ввести имя
+ Показывать номера страницы
+ Включенные источники
+ Доступные источники
+ Динамическая тема
+ Применяет тему приложения, основанную на цветовой палитре обоев на устройстве
+ Политика скриншотов
+ Разрешить
+ Запретить для NSFW
+ Всегда блокировать
+ Рекомендации
+ Включить рекомендации
+ Предлагать мангу на основе Ваших предпочтений
+ Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы
+ Начните читать мангу, чтобы получать персональные предложения
+ Не предлагать NSFW мангу
+ Включено
+ Выключено
+ Не удалось загрузить список жанров
+ Вычисление…
+ Создать проблему на GitHub
+ Импорт манги: %1$d из %2$d
+ Сбросить фильтр
+ Поиск по жанрам
+ Выберите языки, на которых Вы хоите читать мангу. Это можно будет изменить позже в настройках.
+ Никогда
+ Только по Wi-Fi
+ Всегда
+ Предварительная загрузка страниц
+ Вы авторизованы как %s
+ 18+
+ Разные языки
+ Найти главу
+ В этой манге нет глав
+ Оформление
+ Контент
+ Обновление рекомендаций
+ Исключить жанры
+ Укажите жанры, которые Вы не хотите видеть в рекомендациях
+ Удалить выбранную мангу с накопителя?
+ Удаление завершено
+ Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе
+ Загружать параллельно
+ Замедление загрузки
+ Помогает избежать блокировки IP-адреса
+ Обработка сохранённой манги
+ Главы будут удалены в фоновом режиме. Это может занять какое-то время
+ Скрыть
+ Доступны новые источники манги
+ Проверять новые главы и уведомлять о них
+ Вы будете получать уведомления об обновлении манги, которую Вы читаете
+ Вы не будете получать уведомления, но новые главы будут отображаться в списке
+ Включить уведомления
+ Название
+ Изменить
+ Изменить категорию
+ Отслеживание
+ Нет категорий избранного
+ Добавить закладку
+ Удалить закладку
+ Закладки
+ Закладка удалена
+ Закладка добавлена
+ Отменить
+ Удалено из истории
+ DNS через HTTPS
+ Режим по умолчанию
+ Автоопределение режима чтения
+ Автоматически определяет, является ли манга веб-комиксом
+ Отключить оптимизацию батареи
+ Помогает с фоновой проверкой обновлений
+ Что-то пошло не так. Пожалуйста, отправьте отчёт разработчикам, чтобы помочь всё исправить
+ Отправить
+ Отключить все
+ Использовать отпечаток пальца, если доступно
+ Манга из Вашего избранного
+ Манга, которую Вы недавно читали
+ Читаю
+ Запланировано
+ Отложено
+ Заброшено
+ Завершено
+ Показать процент прочитанного в истории и избранном
+ Манга, помеченная как NSFW, никогда не будет добавлена в историю и ваш прогресс чтения не будет сохранен
+ %1$s%%
+ Отчёт
+ Выйти
+ Перечитываю
+ Показать индикаторы прогресса чтения
+ Удаление данных
+ Может помочь в случае каких-либо проблем. Все авторизации будут аннулированы
+ Показать все
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c7516b8e6..ad9539fcd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -360,6 +360,8 @@
Removed from \"%s\"
Options
Content not found or removed
+ Downloading manga
+ <b>%1$s</b> %2$s
Incognito mode
Application update available: %s
No chapters
diff --git a/app/src/test/java/org/koitharu/kotatsu/utils/CompositeMutexTest.kt b/app/src/test/java/org/koitharu/kotatsu/utils/CompositeMutexTest.kt
index d6f1e87ef..97d2e0cf8 100644
--- a/app/src/test/java/org/koitharu/kotatsu/utils/CompositeMutexTest.kt
+++ b/app/src/test/java/org/koitharu/kotatsu/utils/CompositeMutexTest.kt
@@ -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()
mutex.lock(1)
mutex.lock(2)
@@ -22,7 +19,7 @@ class CompositeMutexTest {
}
@Test
- fun testDoubleLock() = runTest {
+ fun doubleLock() = runTest {
val mutex = CompositeMutex()
repeat(2) {
launch(Dispatchers.Default) {
@@ -36,4 +33,20 @@ class CompositeMutexTest {
}
assertNull(tryLock)
}
+
+ @Test
+ fun cancellation() = runTest {
+ val mutex = CompositeMutex()
+ mutex.lock(1)
+ val job = launch {
+ try {
+ mutex.lock(1)
+ } finally {
+ mutex.unlock(1)
+ }
+ }
+ withTimeout(2000) {
+ job.cancelAndJoin()
+ }
+ }
}
\ No newline at end of file