diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt index cd17805d4..86bf848fd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -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 + + companion object : KoinComponent { + + operator fun invoke(source: MangaSource): MangaRepository { + return get(named(source)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index b3d11d3df..e71171d51 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -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(), View.OnClickListener, View.OnLongClickListener { @@ -62,6 +62,7 @@ class DetailsFragment : BaseFragment(), 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) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/DownloadManager.kt new file mode 100644 index 000000000..500542ace --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/DownloadManager.kt @@ -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?, startId: Int): Flow = 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt index 10868d5e3..33b2e938b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt @@ -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) + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt index 2cc0ca30d..dd7e63dae 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt @@ -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() - private val cache by inject() - private val settings by inject() - private val imageLoader by inject() - private val jobs = HashMap() + private val jobs = HashMap>() 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(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?, 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?, + ): LiveStateFlow { + val initialState = DownloadManager.State.Queued(startId, manga, null) + val stateFlow = MutableStateFlow(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().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> + 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? = null) { confirmDataTransfer(context) { val intent = Intent(context, DownloadService::class.java) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 2ab348b6c..c0e728c6f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -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() private val listError = MutableStateFlow(null) private val mangaList = MutableStateFlow?>(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(list.size + 1).apply { + add(headerModel) + list.toUi(this, mode) + } } }.asLiveDataDistinct( viewModelScope.coroutineContext + Dispatchers.Default, diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/LiveStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/LiveStateFlow.kt new file mode 100644 index 000000000..f5b1d2863 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/LiveStateFlow.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.utils + +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow + +class LiveStateFlow( + private val stateFlow: StateFlow, + private val job: Job, +) : StateFlow by stateFlow, Job by job { + + +} \ No newline at end of file