Refactor download service
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
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.*
|
||||
|
||||
interface MangaRepository {
|
||||
@@ -20,4 +23,11 @@ interface MangaRepository {
|
||||
suspend fun getPageUrl(page: MangaPage): String
|
||||
|
||||
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.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
|
||||
View.OnLongClickListener {
|
||||
@@ -62,6 +62,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
textViewTitle.text = manga.title
|
||||
textViewSubtitle.textAndVisible = manga.altTitle
|
||||
textViewAuthor.textAndVisible = manga.author
|
||||
sourceContainer.isVisible = manga.source != MangaSource.LOCAL
|
||||
textViewSource.text = manga.source.title
|
||||
textViewDescription.text =
|
||||
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.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -17,137 +17,126 @@ import org.koitharu.kotatsu.utils.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
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 manager =
|
||||
context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val cancelAction = NotificationCompat.Action(
|
||||
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 {
|
||||
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.setDefaults(0)
|
||||
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
|
||||
}
|
||||
|
||||
fun fillFrom(manga: Manga) {
|
||||
builder.setContentTitle(manga.title)
|
||||
fun create(state: DownloadManager.State): 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.setLargeIcon(null)
|
||||
builder.setContentIntent(null)
|
||||
builder.setStyle(null)
|
||||
}
|
||||
|
||||
fun setCancelId(startId: Int) {
|
||||
if (startId == 0) {
|
||||
builder.clearActions()
|
||||
} else {
|
||||
val intent = DownloadService.getCancelIntent(context, startId)
|
||||
builder.addAction(
|
||||
R.drawable.ic_cross,
|
||||
context.getString(android.R.string.cancel),
|
||||
PendingIntent.getService(
|
||||
context,
|
||||
startId,
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
builder.setLargeIcon(state.cover?.toBitmap())
|
||||
builder.clearActions()
|
||||
when (state) {
|
||||
is DownloadManager.State.Cancelling -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
builder.setContentText(context.getString(R.string.cancelling_))
|
||||
builder.setContentIntent(null)
|
||||
builder.setStyle(null)
|
||||
}
|
||||
is DownloadManager.State.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)
|
||||
}
|
||||
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) {
|
||||
val message = e.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))
|
||||
}
|
||||
|
||||
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()
|
||||
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
|
||||
context,
|
||||
manga.hashCode(),
|
||||
DetailsActivity.newIntent(context, manga),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
const val NOTIFICATION_ID = 201
|
||||
const val CHANNEL_ID = "download"
|
||||
|
||||
private const val CHANNEL_ID = "download"
|
||||
private const val PROGRESS_STEP = 20
|
||||
|
||||
private fun createIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
|
||||
context,
|
||||
manga.hashCode(),
|
||||
DetailsActivity.newIntent(context, manga),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,57 +3,51 @@ package org.koitharu.kotatsu.download
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.IOException
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
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.Manga
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
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.*
|
||||
import java.io.File
|
||||
import org.koitharu.kotatsu.utils.LiveStateFlow
|
||||
import org.koitharu.kotatsu.utils.ext.toArraySet
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class DownloadService : BaseService() {
|
||||
|
||||
private lateinit var notification: DownloadNotification
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
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 cache by inject<PagesCache>()
|
||||
private val settings by inject<AppSettings>()
|
||||
private val imageLoader by inject<ImageLoader>()
|
||||
private val jobs = HashMap<Int, Job>()
|
||||
private val jobs = HashMap<Int, LiveStateFlow<DownloadManager.State>>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notification = DownloadNotification(this)
|
||||
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.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 {
|
||||
@@ -63,8 +57,9 @@ class DownloadService : BaseService() {
|
||||
val manga = intent.getParcelableExtra<Manga>(EXTRA_MANGA)
|
||||
val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet()
|
||||
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()
|
||||
return START_REDELIVER_INTENT
|
||||
} else {
|
||||
stopSelf(startId)
|
||||
}
|
||||
@@ -79,144 +74,59 @@ class DownloadService : BaseService() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun downloadManga(manga: Manga, chaptersIds: Set<Long>?, startId: Int): Job {
|
||||
return lifecycleScope.launch(Dispatchers.Default) {
|
||||
mutex.lock()
|
||||
wakeLock.acquire(TimeUnit.HOURS.toMillis(1))
|
||||
notification.fillFrom(manga)
|
||||
notification.setCancelId(startId)
|
||||
withContext(Dispatchers.Main) {
|
||||
startForeground(DownloadNotification.NOTIFICATION_ID, notification())
|
||||
}
|
||||
val destination = settings.getStorageDir(this@DownloadService)
|
||||
checkNotNull(destination) { getString(R.string.cannot_find_available_storage) }
|
||||
var output: MangaZip? = null
|
||||
try {
|
||||
val repo = mangaRepositoryOf(manga.source)
|
||||
val cover = runCatching {
|
||||
imageLoader.execute(
|
||||
ImageRequest.Builder(this@DownloadService)
|
||||
.data(manga.coverUrl)
|
||||
.build()
|
||||
).drawable
|
||||
}.getOrNull()
|
||||
notification.setLargeIcon(cover)
|
||||
notification.update()
|
||||
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) {
|
||||
notification.setWaitingForNetwork()
|
||||
notification.update()
|
||||
connectivityManager.waitForNetwork()
|
||||
continue@failsafe
|
||||
}
|
||||
} while (false)
|
||||
notification.setProgress(
|
||||
chapters.size,
|
||||
pages.size,
|
||||
chapterIndex,
|
||||
pageIndex
|
||||
)
|
||||
notification.update()
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
dispatcher.close()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
super.onBind(intent)
|
||||
return DownloadBinder()
|
||||
}
|
||||
|
||||
private fun downloadManga(
|
||||
startId: Int,
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
): LiveStateFlow<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(dispatcher) {
|
||||
downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||
.collect { state ->
|
||||
stateFlow.value = state
|
||||
notificationManager.notify(startId, notification.create(state))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ServiceCompat.stopForeground(
|
||||
this@DownloadService,
|
||||
if (isActive) {
|
||||
ServiceCompat.STOP_FOREGROUND_DETACH
|
||||
} else {
|
||||
ServiceCompat.STOP_FOREGROUND_REMOVE
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
wakeLock.release()
|
||||
}
|
||||
mutex.unlock()
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return LiveStateFlow(stateFlow, job)
|
||||
}
|
||||
|
||||
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 = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
inner class DownloadBinder : Binder() {
|
||||
|
||||
val downloads: Collection<LiveStateFlow<DownloadManager.State>>
|
||||
get() = jobs.values
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -230,10 +140,6 @@ class DownloadService : BaseService() {
|
||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||
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) {
|
||||
confirmDataTransfer(context) {
|
||||
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.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
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.list.ui.model.*
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
@@ -36,6 +33,7 @@ class LocalListViewModel(
|
||||
val onMangaRemoved = SingleLiveEvent<Manga>()
|
||||
private val listError = MutableStateFlow<Throwable?>(null)
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
private val headerModel = ListHeader(context.getString(R.string.local_storage))
|
||||
|
||||
override val content = combine(
|
||||
mangaList,
|
||||
@@ -46,7 +44,10 @@ class LocalListViewModel(
|
||||
error != null -> listOf(error.toErrorState(canRetry = true))
|
||||
list == null -> listOf(LoadingState)
|
||||
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(
|
||||
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