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