Refactor download service

This commit is contained in:
Koitharu
2021-07-21 18:23:27 +03:00
parent 625b2769c6
commit ebeaf9703f
7 changed files with 424 additions and 275 deletions

View File

@@ -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))
}
}
} }

View File

@@ -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)

View File

@@ -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"
}
}

View File

@@ -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)
}
}
}
} }
} }

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 {
}