Refactor download service
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.get
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
import org.koitharu.kotatsu.core.model.*
|
import org.koitharu.kotatsu.core.model.*
|
||||||
|
|
||||||
interface MangaRepository {
|
interface MangaRepository {
|
||||||
@@ -20,4 +23,11 @@ interface MangaRepository {
|
|||||||
suspend fun getPageUrl(page: MangaPage): String
|
suspend fun getPageUrl(page: MangaPage): String
|
||||||
|
|
||||||
suspend fun getTags(): Set<MangaTag>
|
suspend fun getTags(): Set<MangaTag>
|
||||||
|
|
||||||
|
companion object : KoinComponent {
|
||||||
|
|
||||||
|
operator fun invoke(source: MangaSource): MangaRepository {
|
||||||
|
return get(named(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -23,13 +23,13 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
|
|||||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||||
import org.koitharu.kotatsu.core.model.Manga
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
||||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||||
import org.koitharu.kotatsu.utils.ext.*
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
|
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
|
||||||
View.OnLongClickListener {
|
View.OnLongClickListener {
|
||||||
@@ -62,6 +62,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
|||||||
textViewTitle.text = manga.title
|
textViewTitle.text = manga.title
|
||||||
textViewSubtitle.textAndVisible = manga.altTitle
|
textViewSubtitle.textAndVisible = manga.altTitle
|
||||||
textViewAuthor.textAndVisible = manga.author
|
textViewAuthor.textAndVisible = manga.author
|
||||||
|
sourceContainer.isVisible = manga.source != MangaSource.LOCAL
|
||||||
textViewSource.text = manga.source.title
|
textViewSource.text = manga.source.title
|
||||||
textViewDescription.text =
|
textViewDescription.text =
|
||||||
manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
|
manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package org.koitharu.kotatsu.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okio.IOException
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.local.data.MangaZip
|
||||||
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
|
import org.koitharu.kotatsu.utils.CacheUtils
|
||||||
|
import org.koitharu.kotatsu.utils.ext.await
|
||||||
|
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||||
|
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class DownloadManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val imageLoader: ImageLoader,
|
||||||
|
private val okHttp: OkHttpClient,
|
||||||
|
private val cache: PagesCache,
|
||||||
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val connectivityManager = context.getSystemService(
|
||||||
|
Context.CONNECTIVITY_SERVICE
|
||||||
|
) as ConnectivityManager
|
||||||
|
private val coverWidth = context.resources.getDimensionPixelSize(
|
||||||
|
androidx.core.R.dimen.compat_notification_large_icon_max_width
|
||||||
|
)
|
||||||
|
private val coverHeight = context.resources.getDimensionPixelSize(
|
||||||
|
androidx.core.R.dimen.compat_notification_large_icon_max_height
|
||||||
|
)
|
||||||
|
|
||||||
|
fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Flow<State> = flow {
|
||||||
|
emit(State.Preparing(startId, manga, null))
|
||||||
|
var cover: Drawable? = null
|
||||||
|
val destination = settings.getStorageDir(context)
|
||||||
|
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
||||||
|
var output: MangaZip? = null
|
||||||
|
try {
|
||||||
|
val repo = MangaRepository(manga.source)
|
||||||
|
cover = runCatching {
|
||||||
|
imageLoader.execute(
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(manga.coverUrl)
|
||||||
|
.size(coverWidth, coverHeight)
|
||||||
|
.build()
|
||||||
|
).drawable
|
||||||
|
}.getOrNull()
|
||||||
|
emit(State.Preparing(startId, manga, cover))
|
||||||
|
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
||||||
|
output = MangaZip.findInDir(destination, data)
|
||||||
|
output.prepare(data)
|
||||||
|
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
||||||
|
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
||||||
|
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||||
|
}
|
||||||
|
val chapters = if (chaptersIds == null) {
|
||||||
|
data.chapters.orEmpty()
|
||||||
|
} else {
|
||||||
|
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
|
||||||
|
}
|
||||||
|
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||||
|
if (chaptersIds == null || chapter.id in chaptersIds) {
|
||||||
|
val pages = repo.getPages(chapter)
|
||||||
|
for ((pageIndex, page) in pages.withIndex()) {
|
||||||
|
failsafe@ do {
|
||||||
|
try {
|
||||||
|
val url = repo.getPageUrl(page)
|
||||||
|
val file =
|
||||||
|
cache[url] ?: downloadFile(url, page.referer, destination)
|
||||||
|
output.addPage(
|
||||||
|
chapter,
|
||||||
|
file,
|
||||||
|
pageIndex,
|
||||||
|
MimeTypeMap.getFileExtensionFromUrl(url)
|
||||||
|
)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
emit(State.WaitingForNetwork(startId, manga, cover))
|
||||||
|
connectivityManager.waitForNetwork()
|
||||||
|
continue@failsafe
|
||||||
|
}
|
||||||
|
} while (false)
|
||||||
|
|
||||||
|
emit(State.Progress(startId, manga, cover,
|
||||||
|
totalChapters = chapters.size,
|
||||||
|
currentChapter = chapterIndex,
|
||||||
|
totalPages = pages.size,
|
||||||
|
currentPage = pageIndex,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(State.PostProcessing(startId, manga, cover))
|
||||||
|
if (!output.compress()) {
|
||||||
|
throw RuntimeException("Cannot create target file")
|
||||||
|
}
|
||||||
|
val localManga = localMangaRepository.getFromFile(output.file)
|
||||||
|
emit(State.Done(startId, manga, cover, localManga))
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
emit(State.Cancelling(startId, manga, cover))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
emit(State.Error(startId, manga, cover, e))
|
||||||
|
} finally {
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
output?.cleanup()
|
||||||
|
File(destination, TEMP_PAGE_FILE).deleteAwait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.catch { e ->
|
||||||
|
emit(State.Error(startId, manga, null, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header(CommonHeaders.REFERER, referer)
|
||||||
|
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
val call = okHttp.newCall(request)
|
||||||
|
var attempts = MAX_DOWNLOAD_ATTEMPTS
|
||||||
|
val file = File(destination, TEMP_PAGE_FILE)
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
val response = call.clone().await()
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
file.outputStream().use { out ->
|
||||||
|
checkNotNull(response.body).byteStream().copyTo(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
} catch (e: IOException) {
|
||||||
|
attempts--
|
||||||
|
if (attempts <= 0) {
|
||||||
|
throw e
|
||||||
|
} else {
|
||||||
|
delay(DOWNLOAD_ERROR_DELAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 companion object {
|
||||||
|
|
||||||
|
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||||
|
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||||
|
private const val TEMP_PAGE_FILE = "page.tmp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -17,137 +17,126 @@ import org.koitharu.kotatsu.utils.PendingIntentCompat
|
|||||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class DownloadNotification(private val context: Context) {
|
class DownloadNotification(
|
||||||
|
private val context: Context,
|
||||||
|
startId: Int,
|
||||||
|
) {
|
||||||
|
|
||||||
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
private val manager =
|
private val cancelAction = NotificationCompat.Action(
|
||||||
context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
R.drawable.ic_cross,
|
||||||
|
context.getString(android.R.string.cancel),
|
||||||
|
PendingIntent.getService(
|
||||||
|
context,
|
||||||
|
startId,
|
||||||
|
DownloadService.getCancelIntent(context, startId),
|
||||||
|
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
|
||||||
&& manager.getNotificationChannel(CHANNEL_ID) == null
|
|
||||||
) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
context.getString(R.string.downloads),
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
)
|
|
||||||
channel.enableVibration(false)
|
|
||||||
channel.enableLights(false)
|
|
||||||
channel.setSound(null, null)
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
builder.setOnlyAlertOnce(true)
|
builder.setOnlyAlertOnce(true)
|
||||||
builder.setDefaults(0)
|
builder.setDefaults(0)
|
||||||
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
|
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fillFrom(manga: Manga) {
|
fun create(state: DownloadManager.State): Notification {
|
||||||
builder.setContentTitle(manga.title)
|
builder.setContentTitle(state.manga.title)
|
||||||
builder.setContentText(context.getString(R.string.manga_downloading_))
|
builder.setContentText(context.getString(R.string.manga_downloading_))
|
||||||
builder.setProgress(1, 0, true)
|
builder.setProgress(1, 0, true)
|
||||||
builder.setSmallIcon(android.R.drawable.stat_sys_download)
|
builder.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
builder.setLargeIcon(null)
|
|
||||||
builder.setContentIntent(null)
|
builder.setContentIntent(null)
|
||||||
builder.setStyle(null)
|
builder.setStyle(null)
|
||||||
}
|
builder.setLargeIcon(state.cover?.toBitmap())
|
||||||
|
builder.clearActions()
|
||||||
fun setCancelId(startId: Int) {
|
when (state) {
|
||||||
if (startId == 0) {
|
is DownloadManager.State.Cancelling -> {
|
||||||
builder.clearActions()
|
builder.setProgress(1, 0, true)
|
||||||
} else {
|
builder.setContentText(context.getString(R.string.cancelling_))
|
||||||
val intent = DownloadService.getCancelIntent(context, startId)
|
builder.setContentIntent(null)
|
||||||
builder.addAction(
|
builder.setStyle(null)
|
||||||
R.drawable.ic_cross,
|
}
|
||||||
context.getString(android.R.string.cancel),
|
is DownloadManager.State.Done -> {
|
||||||
PendingIntent.getService(
|
builder.setProgress(0, 0, false)
|
||||||
context,
|
builder.setContentText(context.getString(R.string.download_complete))
|
||||||
startId,
|
builder.setContentIntent(createMangaIntent(context, state.localManga))
|
||||||
intent,
|
builder.setAutoCancel(true)
|
||||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
)
|
builder.setCategory(null)
|
||||||
)
|
builder.setStyle(null)
|
||||||
|
}
|
||||||
|
is DownloadManager.State.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.setContentIntent(null)
|
||||||
|
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||||
|
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||||
|
}
|
||||||
|
is DownloadManager.State.PostProcessing -> {
|
||||||
|
builder.setProgress(1, 0, true)
|
||||||
|
builder.setContentText(context.getString(R.string.processing_))
|
||||||
|
builder.setStyle(null)
|
||||||
|
}
|
||||||
|
is DownloadManager.State.Queued,
|
||||||
|
is DownloadManager.State.Preparing -> {
|
||||||
|
builder.setProgress(1, 0, true)
|
||||||
|
builder.setContentText(context.getString(R.string.preparing_))
|
||||||
|
builder.setStyle(null)
|
||||||
|
builder.addAction(cancelAction)
|
||||||
|
}
|
||||||
|
is DownloadManager.State.Progress -> {
|
||||||
|
val max = state.totalChapters * PROGRESS_STEP
|
||||||
|
val progress = state.currentChapter * PROGRESS_STEP +
|
||||||
|
(state.currentPage / state.totalPages.toFloat() * PROGRESS_STEP)
|
||||||
|
.roundToInt()
|
||||||
|
val percent = (progress / max.toFloat() * 100).roundToInt()
|
||||||
|
builder.setProgress(max, progress, false)
|
||||||
|
builder.setContentText("%d%%".format(percent))
|
||||||
|
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
builder.setStyle(null)
|
||||||
|
builder.addAction(cancelAction)
|
||||||
|
}
|
||||||
|
is DownloadManager.State.WaitingForNetwork -> {
|
||||||
|
builder.setProgress(0, 0, false)
|
||||||
|
builder.setContentText(context.getString(R.string.waiting_for_network))
|
||||||
|
builder.setStyle(null)
|
||||||
|
builder.addAction(cancelAction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setError(e: Throwable) {
|
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
|
||||||
val message = e.getDisplayMessage(context.resources)
|
context,
|
||||||
builder.setProgress(0, 0, false)
|
manga.hashCode(),
|
||||||
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
DetailsActivity.newIntent(context, manga),
|
||||||
builder.setSubText(context.getString(R.string.error))
|
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||||
builder.setContentText(message)
|
)
|
||||||
builder.setAutoCancel(true)
|
|
||||||
builder.setContentIntent(null)
|
|
||||||
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
|
||||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLargeIcon(icon: Drawable?) {
|
|
||||||
builder.setLargeIcon(icon?.toBitmap())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setProgress(chaptersTotal: Int, pagesTotal: Int, chapter: Int, page: Int) {
|
|
||||||
val max = chaptersTotal * PROGRESS_STEP
|
|
||||||
val progress =
|
|
||||||
chapter * PROGRESS_STEP + (page / pagesTotal.toFloat() * PROGRESS_STEP).roundToInt()
|
|
||||||
val percent = (progress / max.toFloat() * 100).roundToInt()
|
|
||||||
builder.setProgress(max, progress, false)
|
|
||||||
builder.setContentText("%d%%".format(percent))
|
|
||||||
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
|
||||||
builder.setStyle(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWaitingForNetwork() {
|
|
||||||
builder.setProgress(0, 0, false)
|
|
||||||
builder.setContentText(context.getString(R.string.waiting_for_network))
|
|
||||||
builder.setStyle(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPostProcessing() {
|
|
||||||
builder.setProgress(1, 0, true)
|
|
||||||
builder.setContentText(context.getString(R.string.processing_))
|
|
||||||
builder.setStyle(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDone(manga: Manga) {
|
|
||||||
builder.setProgress(0, 0, false)
|
|
||||||
builder.setContentText(context.getString(R.string.download_complete))
|
|
||||||
builder.setContentIntent(createIntent(context, manga))
|
|
||||||
builder.setAutoCancel(true)
|
|
||||||
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
||||||
builder.setCategory(null)
|
|
||||||
builder.setStyle(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCancelling() {
|
|
||||||
builder.setProgress(1, 0, true)
|
|
||||||
builder.setContentText(context.getString(R.string.cancelling_))
|
|
||||||
builder.setContentIntent(null)
|
|
||||||
builder.setStyle(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(id: Int = NOTIFICATION_ID) {
|
|
||||||
manager.notify(id, builder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismiss(id: Int = NOTIFICATION_ID) {
|
|
||||||
manager.cancel(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun invoke(): Notification = builder.build()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val NOTIFICATION_ID = 201
|
private const val CHANNEL_ID = "download"
|
||||||
const val CHANNEL_ID = "download"
|
|
||||||
|
|
||||||
private const val PROGRESS_STEP = 20
|
private const val PROGRESS_STEP = 20
|
||||||
|
|
||||||
private fun createIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
|
fun createChannel(context: Context) {
|
||||||
context,
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
manga.hashCode(),
|
val manager = NotificationManagerCompat.from(context)
|
||||||
DetailsActivity.newIntent(context, manga),
|
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
val channel = NotificationChannel(
|
||||||
)
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.downloads),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
channel.enableVibration(false)
|
||||||
|
channel.enableLights(false)
|
||||||
|
channel.setSound(null, null)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,57 +3,51 @@ package org.koitharu.kotatsu.download
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import coil.ImageLoader
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import okhttp3.OkHttpClient
|
import kotlinx.coroutines.sync.withLock
|
||||||
import okhttp3.Request
|
|
||||||
import okio.IOException
|
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.android.ext.android.inject
|
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseService
|
import org.koitharu.kotatsu.base.ui.BaseService
|
||||||
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
|
||||||
import org.koitharu.kotatsu.core.model.Manga
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.local.data.MangaZip
|
import org.koitharu.kotatsu.utils.LiveStateFlow
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.utils.ext.toArraySet
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import java.util.concurrent.Executors
|
||||||
import org.koitharu.kotatsu.utils.CacheUtils
|
|
||||||
import org.koitharu.kotatsu.utils.ext.*
|
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.collections.set
|
import kotlin.collections.set
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
|
|
||||||
class DownloadService : BaseService() {
|
class DownloadService : BaseService() {
|
||||||
|
|
||||||
private lateinit var notification: DownloadNotification
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
private lateinit var wakeLock: PowerManager.WakeLock
|
private lateinit var wakeLock: PowerManager.WakeLock
|
||||||
private lateinit var connectivityManager: ConnectivityManager
|
private lateinit var downloadManager: DownloadManager
|
||||||
|
private lateinit var dispatcher: ExecutorCoroutineDispatcher
|
||||||
|
|
||||||
private val okHttp by inject<OkHttpClient>()
|
private val jobs = HashMap<Int, LiveStateFlow<DownloadManager.State>>()
|
||||||
private val cache by inject<PagesCache>()
|
|
||||||
private val settings by inject<AppSettings>()
|
|
||||||
private val imageLoader by inject<ImageLoader>()
|
|
||||||
private val jobs = HashMap<Int, Job>()
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
notification = DownloadNotification(this)
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||||
|
downloadManager = DownloadManager(this, get(), get(), get(), get(), get())
|
||||||
|
dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
|
DownloadNotification.createChannel(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -63,8 +57,9 @@ class DownloadService : BaseService() {
|
|||||||
val manga = intent.getParcelableExtra<Manga>(EXTRA_MANGA)
|
val manga = intent.getParcelableExtra<Manga>(EXTRA_MANGA)
|
||||||
val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
|
val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
|
||||||
if (manga != null) {
|
if (manga != null) {
|
||||||
jobs[startId] = downloadManga(manga, chapters, startId)
|
jobs[startId] = downloadManga(startId, manga, chapters)
|
||||||
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
|
||||||
|
return START_REDELIVER_INTENT
|
||||||
} else {
|
} else {
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
}
|
}
|
||||||
@@ -79,144 +74,59 @@ class DownloadService : BaseService() {
|
|||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
|
override fun onDestroy() {
|
||||||
return lifecycleScope.launch(Dispatchers.Default) {
|
super.onDestroy()
|
||||||
mutex.lock()
|
dispatcher.close()
|
||||||
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
|
}
|
||||||
notification.fillFrom(manga)
|
|
||||||
notification.setCancelId(startId)
|
override fun onBind(intent: Intent): IBinder {
|
||||||
withContext(Dispatchers.Main) {
|
super.onBind(intent)
|
||||||
startForeground(DownloadNotification.NOTIFICATION_ID, notification())
|
return DownloadBinder()
|
||||||
}
|
}
|
||||||
val destination = settings.getStorageDir(this@DownloadService)
|
|
||||||
checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
|
private fun downloadManga(
|
||||||
var output: MangaZip? = null
|
startId: Int,
|
||||||
try {
|
manga: Manga,
|
||||||
val repo = mangaRepositoryOf(manga.source)
|
chaptersIds: Set<Long>?,
|
||||||
val cover = runCatching {
|
): LiveStateFlow<DownloadManager.State> {
|
||||||
imageLoader.execute(
|
val initialState = DownloadManager.State.Queued(startId, manga, null)
|
||||||
ImageRequest.Builder(this@DownloadService)
|
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
|
||||||
.data(manga.coverUrl)
|
val job = lifecycleScope.launch {
|
||||||
.build()
|
mutex.withLock {
|
||||||
).drawable
|
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
|
||||||
}.getOrNull()
|
val notification = DownloadNotification(this@DownloadService, startId)
|
||||||
notification.setLargeIcon(cover)
|
startForeground(startId, notification.create(initialState))
|
||||||
notification.update()
|
try {
|
||||||
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
withContext(dispatcher) {
|
||||||
output = MangaZip.findInDir(destination, data)
|
downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||||
output.prepare(data)
|
.collect { state ->
|
||||||
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
stateFlow.value = state
|
||||||
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
notificationManager.notify(startId, notification.create(state))
|
||||||
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
}
|
||||||
}
|
}
|
||||||
val chapters = if (chaptersIds == null) {
|
} finally {
|
||||||
data.chapters.orEmpty()
|
ServiceCompat.stopForeground(
|
||||||
} else {
|
this@DownloadService,
|
||||||
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
|
if (isActive) {
|
||||||
}
|
ServiceCompat.STOP_FOREGROUND_DETACH
|
||||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
} else {
|
||||||
if (chaptersIds == null || chapter.id in chaptersIds) {
|
ServiceCompat.STOP_FOREGROUND_REMOVE
|
||||||
val pages = repo.getPages(chapter)
|
|
||||||
for ((pageIndex, page) in pages.withIndex()) {
|
|
||||||
failsafe@ do {
|
|
||||||
try {
|
|
||||||
val url = repo.getPageUrl(page)
|
|
||||||
val file =
|
|
||||||
cache[url] ?: downloadFile(url, page.referer, destination)
|
|
||||||
output.addPage(
|
|
||||||
chapter,
|
|
||||||
file,
|
|
||||||
pageIndex,
|
|
||||||
MimeTypeMap.getFileExtensionFromUrl(url)
|
|
||||||
)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
notification.setWaitingForNetwork()
|
|
||||||
notification.update()
|
|
||||||
connectivityManager.waitForNetwork()
|
|
||||||
continue@failsafe
|
|
||||||
}
|
|
||||||
} while (false)
|
|
||||||
notification.setProgress(
|
|
||||||
chapters.size,
|
|
||||||
pages.size,
|
|
||||||
chapterIndex,
|
|
||||||
pageIndex
|
|
||||||
)
|
|
||||||
notification.update()
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
|
||||||
notification.setCancelId(0)
|
|
||||||
notification.setPostProcessing()
|
|
||||||
notification.update()
|
|
||||||
if (!output.compress()) {
|
|
||||||
throw RuntimeException("Cannot create target file")
|
|
||||||
}
|
|
||||||
val result = get<LocalMangaRepository>().getFromFile(output.file)
|
|
||||||
notification.setDone(result)
|
|
||||||
notification.dismiss()
|
|
||||||
notification.update(manga.id.toInt().absoluteValue)
|
|
||||||
} catch (_: CancellationException) {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
notification.setCancelling()
|
|
||||||
notification.setCancelId(0)
|
|
||||||
notification.update()
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
notification.setError(e)
|
|
||||||
notification.setCancelId(0)
|
|
||||||
notification.dismiss()
|
|
||||||
notification.update(manga.id.toInt().absoluteValue)
|
|
||||||
} finally {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
jobs.remove(startId)
|
|
||||||
output?.cleanup()
|
|
||||||
destination.sub(TEMP_PAGE_FILE).deleteAwait()
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
stopForeground(true)
|
|
||||||
notification.dismiss()
|
|
||||||
stopSelf(startId)
|
|
||||||
}
|
|
||||||
if (wakeLock.isHeld) {
|
if (wakeLock.isHeld) {
|
||||||
wakeLock.release()
|
wakeLock.release()
|
||||||
}
|
}
|
||||||
mutex.unlock()
|
stopSelf(startId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return LiveStateFlow(stateFlow, job)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
|
inner class DownloadBinder : Binder() {
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
val downloads: Collection<LiveStateFlow<DownloadManager.State>>
|
||||||
.header(CommonHeaders.REFERER, referer)
|
get() = jobs.values
|
||||||
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
val call = okHttp.newCall(request)
|
|
||||||
var attempts = MAX_DOWNLOAD_ATTEMPTS
|
|
||||||
val file = destination.sub(TEMP_PAGE_FILE)
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
val response = call.clone().await()
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
file.outputStream().use { out ->
|
|
||||||
checkNotNull(response.body).byteStream().copyTo(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return file
|
|
||||||
} catch (e: IOException) {
|
|
||||||
attempts--
|
|
||||||
if (attempts <= 0) {
|
|
||||||
throw e
|
|
||||||
} else {
|
|
||||||
delay(DOWNLOAD_ERROR_DELAY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -230,10 +140,6 @@ class DownloadService : BaseService() {
|
|||||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||||
private const val EXTRA_CANCEL_ID = "cancel_id"
|
private const val EXTRA_CANCEL_ID = "cancel_id"
|
||||||
|
|
||||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
|
||||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
|
||||||
private const val TEMP_PAGE_FILE = "page.tmp"
|
|
||||||
|
|
||||||
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
|
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) {
|
||||||
confirmDataTransfer(context) {
|
confirmDataTransfer(context) {
|
||||||
val intent = Intent(context, DownloadService::class.java)
|
val intent = Intent(context, DownloadService::class.java)
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.*
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
@@ -36,6 +33,7 @@ class LocalListViewModel(
|
|||||||
val onMangaRemoved = SingleLiveEvent<Manga>()
|
val onMangaRemoved = SingleLiveEvent<Manga>()
|
||||||
private val listError = MutableStateFlow<Throwable?>(null)
|
private val listError = MutableStateFlow<Throwable?>(null)
|
||||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||||
|
private val headerModel = ListHeader(context.getString(R.string.local_storage))
|
||||||
|
|
||||||
override val content = combine(
|
override val content = combine(
|
||||||
mangaList,
|
mangaList,
|
||||||
@@ -46,7 +44,10 @@ class LocalListViewModel(
|
|||||||
error != null -> listOf(error.toErrorState(canRetry = true))
|
error != null -> listOf(error.toErrorState(canRetry = true))
|
||||||
list == null -> listOf(LoadingState)
|
list == null -> listOf(LoadingState)
|
||||||
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_storage, R.string.text_local_holder_primary, R.string.text_local_holder_secondary))
|
list.isEmpty() -> listOf(EmptyState(R.drawable.ic_storage, R.string.text_local_holder_primary, R.string.text_local_holder_secondary))
|
||||||
else -> list.toUi(mode)
|
else -> ArrayList<ListModel>(list.size + 1).apply {
|
||||||
|
add(headerModel)
|
||||||
|
list.toUi(this, mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.asLiveDataDistinct(
|
}.asLiveDataDistinct(
|
||||||
viewModelScope.coroutineContext + Dispatchers.Default,
|
viewModelScope.coroutineContext + Dispatchers.Default,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.utils
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class LiveStateFlow<T>(
|
||||||
|
private val stateFlow: StateFlow<T>,
|
||||||
|
private val job: Job,
|
||||||
|
) : StateFlow<T> by stateFlow, Job by job {
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user