Fix manga downloading
This commit is contained in:
@@ -8,9 +8,8 @@ import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Scale
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.IOException
|
||||
@@ -26,13 +25,16 @@ import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.referer
|
||||
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
import java.io.File
|
||||
|
||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||
private const val MAX_PARALLEL_DOWNLOADS = 2
|
||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||
private const val TEMP_PAGE_FILE = "page.tmp"
|
||||
|
||||
class DownloadManager(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val context: Context,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val okHttp: OkHttpClient,
|
||||
@@ -49,9 +51,29 @@ class DownloadManager(
|
||||
private val coverHeight = context.resources.getDimensionPixelSize(
|
||||
androidx.core.R.dimen.compat_notification_large_icon_max_height
|
||||
)
|
||||
private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
|
||||
|
||||
fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Flow<State> = flow {
|
||||
emit(State.Preparing(startId, manga, null))
|
||||
fun downloadManga(
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
startId: Int,
|
||||
): ProgressJob<DownloadState> {
|
||||
val stateFlow = MutableStateFlow<DownloadState>(
|
||||
DownloadState.Queued(startId = startId, manga = manga, cover = null)
|
||||
)
|
||||
val job = downloadMangaImpl(manga, chaptersIds, stateFlow, startId)
|
||||
return ProgressJob(job, stateFlow)
|
||||
}
|
||||
|
||||
private fun downloadMangaImpl(
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
outState: MutableStateFlow<DownloadState>,
|
||||
startId: Int,
|
||||
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
||||
semaphore.acquire()
|
||||
coroutineContext[WakeLockNode]?.acquire()
|
||||
outState.value = DownloadState.Preparing(startId, manga, null)
|
||||
var cover: Drawable? = null
|
||||
val destination = localMangaRepository.getOutputDir()
|
||||
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
||||
@@ -68,7 +90,7 @@ class DownloadManager(
|
||||
.build()
|
||||
).drawable
|
||||
}.getOrNull()
|
||||
emit(State.Preparing(startId, manga, cover))
|
||||
outState.value = DownloadState.Preparing(startId, manga, cover)
|
||||
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
||||
output = MangaZip.findInDir(destination, data)
|
||||
output.prepare(data)
|
||||
@@ -97,45 +119,43 @@ class DownloadManager(
|
||||
MimeTypeMap.getFileExtensionFromUrl(url)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
emit(State.WaitingForNetwork(startId, manga, cover))
|
||||
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
||||
connectivityManager.waitForNetwork()
|
||||
continue@failsafe
|
||||
}
|
||||
} while (false)
|
||||
|
||||
emit(
|
||||
State.Progress(
|
||||
startId, manga, cover,
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageIndex,
|
||||
)
|
||||
outState.value = DownloadState.Progress(
|
||||
startId, data, cover,
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
emit(State.PostProcessing(startId, manga, cover))
|
||||
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
||||
if (!output.compress()) {
|
||||
throw RuntimeException("Cannot create target file")
|
||||
}
|
||||
val localManga = localMangaRepository.getFromFile(output.file)
|
||||
emit(State.Done(startId, manga, cover, localManga))
|
||||
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
||||
} catch (_: CancellationException) {
|
||||
emit(State.Cancelling(startId, manga, cover))
|
||||
outState.value = DownloadState.Cancelled(startId, manga, cover)
|
||||
} catch (e: Throwable) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
emit(State.Error(startId, manga, cover, e))
|
||||
outState.value = DownloadState.Error(startId, manga, cover, e)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
output?.cleanup()
|
||||
File(destination, TEMP_PAGE_FILE).deleteAwait()
|
||||
}
|
||||
coroutineContext[WakeLockNode]?.release()
|
||||
semaphore.release()
|
||||
}
|
||||
}.catch { e ->
|
||||
emit(State.Error(startId, manga, null, e))
|
||||
}
|
||||
|
||||
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
|
||||
@@ -168,71 +188,13 @@ class DownloadManager(
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface State {
|
||||
|
||||
val startId: Int
|
||||
val manga: Manga
|
||||
val cover: Drawable?
|
||||
|
||||
data class Queued(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : State
|
||||
|
||||
data class Preparing(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : State
|
||||
|
||||
data class Progress(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val totalChapters: Int,
|
||||
val currentChapter: Int,
|
||||
val totalPages: Int,
|
||||
val currentPage: Int,
|
||||
) : State {
|
||||
|
||||
val max: Int = totalChapters * totalPages
|
||||
|
||||
val progress: Int = totalPages * currentChapter + currentPage + 1
|
||||
|
||||
val percent: Float = progress.toFloat() / max
|
||||
}
|
||||
|
||||
data class WaitingForNetwork(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : State
|
||||
|
||||
data class Done(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val localManga: Manga,
|
||||
) : State
|
||||
|
||||
data class Error(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val error: Throwable,
|
||||
) : State
|
||||
|
||||
data class Cancelling(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : State
|
||||
|
||||
data class PostProcessing(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : State
|
||||
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable ->
|
||||
val prevValue = outState.value
|
||||
outState.value = DownloadState.Error(
|
||||
startId = prevValue.startId,
|
||||
manga = prevValue.manga,
|
||||
cover = prevValue.cover,
|
||||
error = throwable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package org.koitharu.kotatsu.download.domain
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
sealed interface DownloadState {
|
||||
|
||||
val startId: Int
|
||||
val manga: Manga
|
||||
val cover: Drawable?
|
||||
|
||||
class Queued(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : DownloadState {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Queued
|
||||
|
||||
if (startId != other.startId) return false
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startId
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class Preparing(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : DownloadState {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Preparing
|
||||
|
||||
if (startId != other.startId) return false
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startId
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class Progress(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val totalChapters: Int,
|
||||
val currentChapter: Int,
|
||||
val totalPages: Int,
|
||||
val currentPage: Int,
|
||||
) : DownloadState {
|
||||
|
||||
val max: Int = totalChapters * totalPages
|
||||
|
||||
val progress: Int = totalPages * currentChapter + currentPage + 1
|
||||
|
||||
val percent: Float = progress.toFloat() / max
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Progress
|
||||
|
||||
if (startId != other.startId) return false
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
if (totalChapters != other.totalChapters) return false
|
||||
if (currentChapter != other.currentChapter) return false
|
||||
if (totalPages != other.totalPages) return false
|
||||
if (currentPage != other.currentPage) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startId
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
result = 31 * result + totalChapters
|
||||
result = 31 * result + currentChapter
|
||||
result = 31 * result + totalPages
|
||||
result = 31 * result + currentPage
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class WaitingForNetwork(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : DownloadState {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as WaitingForNetwork
|
||||
|
||||
if (startId != other.startId) return false
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startId
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class Done(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val localManga: Manga,
|
||||
) : DownloadState {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Done
|
||||
|
||||
if (startId != other.startId) return false
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
if (localManga != other.localManga) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startId
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
result = 31 * result + localManga.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class Error(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val error: Throwable,
|
||||
) : DownloadState {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Error
|
||||
|
||||
if (startId != other.startId) return false
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
if (error != other.error) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startId
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
result = 31 * result + error.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class Cancelled(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : DownloadState {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Cancelled
|
||||
|
||||
if (startId != other.startId) return false
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startId
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class PostProcessing(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
) : DownloadState {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PostProcessing
|
||||
|
||||
if (startId != other.startId) return false
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startId
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.download.domain
|
||||
|
||||
import android.os.PowerManager
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class WakeLockNode(
|
||||
private val wakeLock: PowerManager.WakeLock,
|
||||
private val timeout: Long,
|
||||
) : AbstractCoroutineContextElement(Key) {
|
||||
|
||||
init {
|
||||
wakeLock.setReferenceCounted(true)
|
||||
}
|
||||
|
||||
fun acquire() {
|
||||
wakeLock.acquire(timeout)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
wakeLock.release()
|
||||
}
|
||||
|
||||
companion object Key : CoroutineContext.Key<WakeLockNode>
|
||||
}
|
||||
@@ -9,14 +9,14 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
|
||||
fun downloadItemAD(
|
||||
scope: CoroutineScope,
|
||||
coil: ImageLoader,
|
||||
) = adapterDelegateViewBinding<ProgressJob<DownloadManager.State>, ProgressJob<DownloadManager.State>, ItemDownloadBinding>(
|
||||
) = adapterDelegateViewBinding<ProgressJob<DownloadState>, ProgressJob<DownloadState>, ItemDownloadBinding>(
|
||||
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
@@ -36,21 +36,21 @@ fun downloadItemAD(
|
||||
}.onEach { state ->
|
||||
binding.textViewTitle.text = state.manga.title
|
||||
when (state) {
|
||||
is DownloadManager.State.Cancelling -> {
|
||||
is DownloadState.Cancelled -> {
|
||||
binding.textViewStatus.setText(R.string.cancelling_)
|
||||
binding.progressBar.isIndeterminate = true
|
||||
binding.progressBar.isVisible = true
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Done -> {
|
||||
is DownloadState.Done -> {
|
||||
binding.textViewStatus.setText(R.string.download_complete)
|
||||
binding.progressBar.isIndeterminate = false
|
||||
binding.progressBar.isVisible = false
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Error -> {
|
||||
is DownloadState.Error -> {
|
||||
binding.textViewStatus.setText(R.string.error_occurred)
|
||||
binding.progressBar.isIndeterminate = false
|
||||
binding.progressBar.isVisible = false
|
||||
@@ -58,21 +58,21 @@ fun downloadItemAD(
|
||||
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
|
||||
binding.textViewDetails.isVisible = true
|
||||
}
|
||||
is DownloadManager.State.PostProcessing -> {
|
||||
is DownloadState.PostProcessing -> {
|
||||
binding.textViewStatus.setText(R.string.processing_)
|
||||
binding.progressBar.isIndeterminate = true
|
||||
binding.progressBar.isVisible = true
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Preparing -> {
|
||||
is DownloadState.Preparing -> {
|
||||
binding.textViewStatus.setText(R.string.preparing_)
|
||||
binding.progressBar.isIndeterminate = true
|
||||
binding.progressBar.isVisible = true
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Progress -> {
|
||||
is DownloadState.Progress -> {
|
||||
binding.textViewStatus.setText(R.string.manga_downloading_)
|
||||
binding.progressBar.isIndeterminate = false
|
||||
binding.progressBar.isVisible = true
|
||||
@@ -82,14 +82,14 @@ fun downloadItemAD(
|
||||
binding.textViewPercent.isVisible = true
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.Queued -> {
|
||||
is DownloadState.Queued -> {
|
||||
binding.textViewStatus.setText(R.string.queued)
|
||||
binding.progressBar.isIndeterminate = false
|
||||
binding.progressBar.isVisible = false
|
||||
binding.textViewPercent.isVisible = false
|
||||
binding.textViewDetails.isVisible = false
|
||||
}
|
||||
is DownloadManager.State.WaitingForNetwork -> {
|
||||
is DownloadState.WaitingForNetwork -> {
|
||||
binding.textViewStatus.setText(R.string.waiting_for_network)
|
||||
binding.progressBar.isIndeterminate = false
|
||||
binding.progressBar.isVisible = false
|
||||
|
||||
@@ -4,13 +4,13 @@ import androidx.recyclerview.widget.DiffUtil
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
|
||||
class DownloadsAdapter(
|
||||
scope: CoroutineScope,
|
||||
coil: ImageLoader,
|
||||
) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadManager.State>>(DiffCallback()) {
|
||||
) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadState>>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(downloadItemAD(scope, coil))
|
||||
@@ -21,18 +21,18 @@ class DownloadsAdapter(
|
||||
return items[position].progressValue.startId.toLong()
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadManager.State>>() {
|
||||
private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadState>>() {
|
||||
|
||||
override fun areItemsTheSame(
|
||||
oldItem: ProgressJob<DownloadManager.State>,
|
||||
newItem: ProgressJob<DownloadManager.State>,
|
||||
oldItem: ProgressJob<DownloadState>,
|
||||
newItem: ProgressJob<DownloadState>,
|
||||
): Boolean {
|
||||
return oldItem.progressValue.startId == newItem.progressValue.startId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: ProgressJob<DownloadManager.State>,
|
||||
newItem: ProgressJob<DownloadManager.State>,
|
||||
oldItem: ProgressJob<DownloadState>,
|
||||
newItem: ProgressJob<DownloadState>,
|
||||
): Boolean {
|
||||
return oldItem.progressValue == newItem.progressValue
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import androidx.core.graphics.drawable.toBitmap
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.CrashActivity
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
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.utils.PendingIntentCompat
|
||||
@@ -21,10 +21,7 @@ import org.koitharu.kotatsu.utils.ext.format
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class DownloadNotification(
|
||||
private val context: Context,
|
||||
startId: Int,
|
||||
) {
|
||||
class DownloadNotification(private val context: Context, startId: Int) {
|
||||
|
||||
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
private val cancelAction = NotificationCompat.Action(
|
||||
@@ -48,9 +45,11 @@ class DownloadNotification(
|
||||
builder.setOnlyAlertOnce(true)
|
||||
builder.setDefaults(0)
|
||||
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
|
||||
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
|
||||
builder.setSilent(true)
|
||||
}
|
||||
|
||||
fun create(state: DownloadManager.State): Notification {
|
||||
fun create(state: DownloadState): Notification {
|
||||
builder.setContentTitle(state.manga.title)
|
||||
builder.setContentText(context.getString(R.string.manga_downloading_))
|
||||
builder.setProgress(1, 0, true)
|
||||
@@ -60,13 +59,14 @@ class DownloadNotification(
|
||||
builder.setLargeIcon(state.cover?.toBitmap())
|
||||
builder.clearActions()
|
||||
when (state) {
|
||||
is DownloadManager.State.Cancelling -> {
|
||||
is DownloadState.Cancelled -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.cancelling_))
|
||||
builder.setContentIntent(null)
|
||||
builder.setStyle(null)
|
||||
builder.setOngoing(true)
|
||||
}
|
||||
is DownloadManager.State.Done -> {
|
||||
is DownloadState.Done -> {
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setContentText(context.getString(R.string.download_complete))
|
||||
builder.setContentIntent(createMangaIntent(context, state.localManga))
|
||||
@@ -74,14 +74,16 @@ class DownloadNotification(
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
builder.setCategory(null)
|
||||
builder.setStyle(null)
|
||||
builder.setOngoing(false)
|
||||
}
|
||||
is DownloadManager.State.Error -> {
|
||||
is DownloadState.Error -> {
|
||||
val message = state.error.getDisplayMessage(context.resources)
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
builder.setSubText(context.getString(R.string.error))
|
||||
builder.setContentText(message)
|
||||
builder.setAutoCancel(true)
|
||||
builder.setOngoing(false)
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
@@ -93,29 +95,39 @@ class DownloadNotification(
|
||||
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
}
|
||||
is DownloadManager.State.PostProcessing -> {
|
||||
is DownloadState.PostProcessing -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.processing_))
|
||||
builder.setStyle(null)
|
||||
builder.setOngoing(true)
|
||||
}
|
||||
is DownloadManager.State.Queued,
|
||||
is DownloadManager.State.Preparing -> {
|
||||
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 DownloadManager.State.Progress -> {
|
||||
is DownloadState.Progress -> {
|
||||
builder.setProgress(state.max, state.progress, false)
|
||||
builder.setContentText((state.percent * 100).format() + "%")
|
||||
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
builder.setStyle(null)
|
||||
builder.setOngoing(true)
|
||||
builder.addAction(cancelAction)
|
||||
}
|
||||
is DownloadManager.State.WaitingForNetwork -> {
|
||||
is DownloadState.WaitingForNetwork -> {
|
||||
builder.setProgress(0, 0, false)
|
||||
builder.setContentText(context.getString(R.string.waiting_for_network))
|
||||
builder.setStyle(null)
|
||||
builder.setOngoing(true)
|
||||
builder.addAction(cancelAction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,14 @@ import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
@@ -29,10 +23,14 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseService
|
||||
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.withoutChapters
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||
import org.koitharu.kotatsu.download.domain.WakeLockNode
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.utils.ext.throttle
|
||||
import org.koitharu.kotatsu.utils.ext.toArraySet
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -40,22 +38,27 @@ import kotlin.collections.set
|
||||
|
||||
class DownloadService : BaseService() {
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
private lateinit var downloadManager: DownloadManager
|
||||
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher
|
||||
|
||||
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadManager.State>>()
|
||||
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadState>>()
|
||||
private val jobCount = MutableStateFlow(0)
|
||||
private val mutex = Mutex()
|
||||
private val controlReceiver = ControlReceiver()
|
||||
private var binder: DownloadBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
notificationSwitcher = ForegroundNotificationSwitcher(this)
|
||||
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||
downloadManager = DownloadManager(this, get(), get(), get(), get())
|
||||
downloadManager = DownloadManager(
|
||||
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
|
||||
context = this,
|
||||
imageLoader = get(),
|
||||
okHttp = get(),
|
||||
cache = get(),
|
||||
localMangaRepository = get(),
|
||||
)
|
||||
DownloadNotification.createChannel(this)
|
||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||
}
|
||||
@@ -95,48 +98,50 @@ class DownloadService : BaseService() {
|
||||
startId: Int,
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
): ProgressJob<DownloadManager.State> {
|
||||
val initialState = DownloadManager.State.Queued(startId, manga, null)
|
||||
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
|
||||
val job = lifecycleScope.launch {
|
||||
mutex.withLock {
|
||||
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
|
||||
val notification = DownloadNotification(this@DownloadService, startId)
|
||||
startForeground(startId, notification.create(initialState))
|
||||
try {
|
||||
withContext(Dispatchers.Default) {
|
||||
downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||
.distinctUntilChanged()
|
||||
.collect { state ->
|
||||
stateFlow.value = state
|
||||
notificationManager.notify(startId, notification.create(state))
|
||||
}
|
||||
}
|
||||
if (stateFlow.value is DownloadManager.State.Done) {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_DOWNLOAD_COMPLETE)
|
||||
.putExtra(EXTRA_MANGA, ParcelableManga(manga))
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
ServiceCompat.stopForeground(
|
||||
this@DownloadService,
|
||||
if (isActive) {
|
||||
ServiceCompat.STOP_FOREGROUND_DETACH
|
||||
} else {
|
||||
ServiceCompat.STOP_FOREGROUND_REMOVE
|
||||
}
|
||||
)
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ProgressJob(job, stateFlow)
|
||||
): ProgressJob<DownloadState> {
|
||||
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||
listenJob(job)
|
||||
return job
|
||||
}
|
||||
|
||||
private fun listenJob(job: ProgressJob<DownloadState>) {
|
||||
lifecycleScope.launch {
|
||||
val startId = job.progressValue.startId
|
||||
val notification = DownloadNotification(this@DownloadService, startId)
|
||||
notificationSwitcher.notify(startId, notification.create(job.progressValue))
|
||||
job.progressAsFlow()
|
||||
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
|
||||
.whileActive()
|
||||
.collect { state ->
|
||||
notificationSwitcher.notify(startId, notification.create(state))
|
||||
}
|
||||
job.join()
|
||||
(job.progressValue as? DownloadState.Done)?.let {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_DOWNLOAD_COMPLETE)
|
||||
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters()))
|
||||
)
|
||||
}
|
||||
notificationSwitcher.detach(
|
||||
startId,
|
||||
if (job.isCancelled) {
|
||||
null
|
||||
} else {
|
||||
notification.create(job.progressValue)
|
||||
}
|
||||
)
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Flow<DownloadState>.whileActive(): Flow<DownloadState> = transformWhile { state ->
|
||||
emit(state)
|
||||
!state.isTerminal
|
||||
}
|
||||
|
||||
private val DownloadState.isTerminal: Boolean
|
||||
get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled
|
||||
|
||||
inner class ControlReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
@@ -152,7 +157,7 @@ class DownloadService : BaseService() {
|
||||
|
||||
class DownloadBinder(private val service: DownloadService) : Binder() {
|
||||
|
||||
val downloads: Flow<Collection<ProgressJob<DownloadManager.State>>>
|
||||
val downloads: Flow<Collection<ProgressJob<DownloadState>>>
|
||||
get() = service.jobCount.mapLatest { service.jobs.values }
|
||||
}
|
||||
|
||||
@@ -185,6 +190,13 @@ class DownloadService : BaseService() {
|
||||
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
|
||||
.putExtra(EXTRA_CANCEL_ID, startId)
|
||||
|
||||
fun getDownloadedManga(intent: Intent?): Manga? {
|
||||
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
|
||||
return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
|
||||
val settings = GlobalContext.get().get<AppSettings>()
|
||||
if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.koitharu.kotatsu.download.ui.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.SparseArray
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.util.isEmpty
|
||||
import androidx.core.util.size
|
||||
|
||||
private const val DEFAULT_DELAY = 500L
|
||||
|
||||
class ForegroundNotificationSwitcher(
|
||||
private val service: Service,
|
||||
) {
|
||||
|
||||
private val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val notifications = SparseArray<Notification>()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
@Synchronized
|
||||
fun notify(startId: Int, notification: Notification) {
|
||||
if (notifications.isEmpty()) {
|
||||
handler.postDelayed(StartForegroundRunnable(startId, notification), DEFAULT_DELAY)
|
||||
}
|
||||
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 StartForegroundRunnable(
|
||||
private val startId: Int,
|
||||
private val notification: Notification,
|
||||
) : Runnable {
|
||||
|
||||
override fun run() {
|
||||
service.startForeground(startId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user