diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt index d079eb51f..9f5b34dd4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -1,11 +1,11 @@ package org.koitharu.kotatsu.download.domain import android.content.Context -import android.net.ConnectivityManager import android.webkit.MimeTypeMap import coil.ImageLoader import coil.request.ImageRequest import coil.size.Scale +import java.io.File import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Semaphore @@ -16,6 +16,7 @@ import org.koitharu.kotatsu.R 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.download.ui.service.PausingHandle import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.domain.CbzMangaOutput import org.koitharu.kotatsu.local.domain.LocalMangaRepository @@ -25,11 +26,9 @@ import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.referer -import org.koitharu.kotatsu.utils.ext.waitForNetwork -import org.koitharu.kotatsu.utils.progress.ProgressJob -import java.io.File +import org.koitharu.kotatsu.utils.progress.PausingProgressJob -private const val MAX_DOWNLOAD_ATTEMPTS = 3 +private const val MAX_FAILSAFE_ATTEMPTS = 2 private const val DOWNLOAD_ERROR_DELAY = 500L private const val SLOWDOWN_DELAY = 200L @@ -43,9 +42,6 @@ class DownloadManager( private val settings: AppSettings, ) { - 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 ) @@ -58,21 +54,24 @@ class DownloadManager( manga: Manga, chaptersIds: LongArray?, startId: Int, - ): ProgressJob { + ): PausingProgressJob { val stateFlow = MutableStateFlow( DownloadState.Queued(startId = startId, manga = manga, cover = null) ) - val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId) - return ProgressJob(job, stateFlow) + val pausingHandle = PausingHandle() + val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId) + return PausingProgressJob(job, stateFlow, pausingHandle) } private fun downloadMangaImpl( manga: Manga, chaptersIds: LongArray?, outState: MutableStateFlow, + pausingHandle: PausingHandle, startId: Int, ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { - @Suppress("NAME_SHADOWING") var manga = manga + @Suppress("NAME_SHADOWING") + var manga = manga val chaptersIdsSet = chaptersIds?.toMutableSet() val cover = loadCover(manga) outState.value = DownloadState.Queued(startId, manga, cover) @@ -108,38 +107,28 @@ class DownloadManager( "${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga" } for ((chapterIndex, chapter) in chapters.withIndex()) { - val pages = repo.getPages(chapter) + val pages = runFailsafe(outState, pausingHandle) { + repo.getPages(chapter) + } for ((pageIndex, page) in pages.withIndex()) { - var retryCounter = 0 - failsafe@ while (true) { - try { - val url = repo.getPageUrl(page) - val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName) - output.addPage( - chapter = chapter, - file = file, - pageNumber = pageIndex, - ext = MimeTypeMap.getFileExtensionFromUrl(url), - ) - break@failsafe - } catch (e: IOException) { - if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) { - outState.value = DownloadState.WaitingForNetwork(startId, data, cover) - delay(DOWNLOAD_ERROR_DELAY) - connectivityManager.waitForNetwork() - retryCounter++ - } else { - throw e - } - } + runFailsafe(outState, pausingHandle) { + val url = repo.getPageUrl(page) + val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName) + output.addPage( + chapter = chapter, + file = file, + pageNumber = pageIndex, + ext = MimeTypeMap.getFileExtensionFromUrl(url) + ) } - outState.value = DownloadState.Progress( - startId, data, cover, + startId = startId, + manga = data, + cover = cover, totalChapters = chapters.size, currentChapter = chapterIndex, totalPages = pages.size, - currentPage = pageIndex, + currentPage = pageIndex ) if (settings.isDownloadsSlowdownEnabled) { @@ -157,15 +146,40 @@ class DownloadManager( throw e } catch (e: Throwable) { e.printStackTraceDebug() - outState.value = DownloadState.Error(startId, manga, cover, e) + outState.value = DownloadState.Error(startId, manga, cover, e, false) } finally { withContext(NonCancellable) { output?.cleanup() File(destination, tempFileName).deleteAwait() + coroutineContext[WakeLockNode]?.release() + semaphore.release() + localMangaRepository.unlockManga(manga.id) + } + } + } + + private suspend fun runFailsafe( + outState: MutableStateFlow, + pausingHandle: PausingHandle, + block: suspend () -> R, + ): R { + var countDown = MAX_FAILSAFE_ATTEMPTS + failsafe@ while (true) { + try { + return block() + } catch (e: IOException) { + if (countDown <= 0) { + val state = outState.value + outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true) + countDown = MAX_FAILSAFE_ATTEMPTS + pausingHandle.pause() + pausingHandle.awaitResumed() + outState.value = state + } else { + countDown-- + delay(DOWNLOAD_ERROR_DELAY) + } } - coroutineContext[WakeLockNode]?.release() - semaphore.release() - localMangaRepository.unlockManga(manga.id) } } @@ -195,6 +209,7 @@ class DownloadManager( manga = prevValue.manga, cover = prevValue.cover, error = throwable, + canRetry = false ) } @@ -225,7 +240,7 @@ class DownloadManager( okHttp = okHttp, cache = cache, localMangaRepository = localMangaRepository, - settings = settings, + settings = settings ) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt index a0a78ac7a..d7f86abfb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt @@ -108,6 +108,7 @@ sealed interface DownloadState { } } + @Deprecated("TODO: remove") class WaitingForNetwork( override val startId: Int, override val manga: Manga, @@ -170,6 +171,7 @@ sealed interface DownloadState { override val manga: Manga, override val cover: Drawable?, val error: Throwable, + val canRetry: Boolean, ) : DownloadState { override fun equals(other: Any?): Boolean { @@ -182,6 +184,7 @@ sealed interface DownloadState { if (manga != other.manga) return false if (cover != other.cover) return false if (error != other.error) return false + if (canRetry != other.canRetry) return false return true } @@ -191,6 +194,7 @@ sealed interface DownloadState { result = 31 * result + manga.hashCode() result = 31 * result + (cover?.hashCode() ?: 0) result = 31 * result + error.hashCode() + result = 31 * result + canRetry.hashCode() return result } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt index a8f0744bd..399340aa3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -29,16 +29,26 @@ class DownloadNotification(private val context: Context, startId: Int) { context.getString(android.R.string.cancel), PendingIntent.getBroadcast( context, - startId, + startId * 2, DownloadService.getCancelIntent(startId), PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE ) ) + private val retryAction = NotificationCompat.Action( + R.drawable.ic_restart_black, + context.getString(R.string.try_again), + PendingIntent.getBroadcast( + context, + startId * 2 + 1, + DownloadService.getResumeIntent(startId), + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + ) private val listIntent = PendingIntent.getActivity( context, REQUEST_LIST, DownloadsActivity.newIntent(context), - PendingIntentCompat.FLAG_IMMUTABLE, + PendingIntentCompat.FLAG_IMMUTABLE ) init { @@ -89,10 +99,14 @@ class DownloadNotification(private val context: Context, startId: Int) { builder.setSmallIcon(android.R.drawable.stat_notify_error) builder.setSubText(context.getString(R.string.error)) builder.setContentText(message) - builder.setAutoCancel(true) - builder.setOngoing(false) + builder.setAutoCancel(!state.canRetry) + builder.setOngoing(state.canRetry) builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) + if (state.canRetry) { + builder.addAction(cancelAction) + builder.addAction(retryAction) + } } is DownloadState.PostProcessing -> { builder.setProgress(1, 0, true) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 91c742e73..96a3a4c55 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -11,6 +11,7 @@ import android.widget.Toast import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.plus @@ -28,16 +29,16 @@ import org.koitharu.kotatsu.download.domain.WakeLockNode import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.throttle +import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator -import java.util.concurrent.TimeUnit class DownloadService : BaseService() { private lateinit var downloadManager: DownloadManager private lateinit var notificationSwitcher: ForegroundNotificationSwitcher - private val jobs = LinkedHashMap>() + private val jobs = LinkedHashMap>() private val jobCount = MutableStateFlow(0) private val controlReceiver = ControlReceiver() private var binder: DownloadBinder? = null @@ -49,10 +50,13 @@ class DownloadService : BaseService() { val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") downloadManager = get().create( - coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), + coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)) ) DownloadNotification.createChannel(this) - registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL)) + val intentFilter = IntentFilter() + intentFilter.addAction(ACTION_DOWNLOAD_CANCEL) + intentFilter.addAction(ACTION_DOWNLOAD_RESUME) + registerReceiver(controlReceiver, intentFilter) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -90,7 +94,7 @@ class DownloadService : BaseService() { startId: Int, manga: Manga, chaptersIds: LongArray?, - ): ProgressJob { + ): PausingProgressJob { val job = downloadManager.downloadManga(manga, chaptersIds, startId) listenJob(job) return job @@ -144,7 +148,7 @@ class DownloadService : BaseService() { } private val DownloadState.isTerminal: Boolean - get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled + get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry) inner class ControlReceiver : BroadcastReceiver() { @@ -155,6 +159,10 @@ class DownloadService : BaseService() { jobs.remove(cancelId)?.cancel() jobCount.value = jobs.size } + ACTION_DOWNLOAD_RESUME -> { + val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) + jobs[cancelId]?.resume() + } } } } @@ -173,6 +181,7 @@ class DownloadService : BaseService() { const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE" private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" + private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME" private const val EXTRA_MANGA = "manga" private const val EXTRA_CHAPTERS_IDS = "chapters_ids" @@ -219,6 +228,9 @@ class DownloadService : BaseService() { fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) .putExtra(EXTRA_CANCEL_ID, startId) + fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME) + .putExtra(EXTRA_CANCEL_ID, startId) + fun getDownloadedManga(intent: Intent?): Manga? { if (intent?.action == ACTION_DOWNLOAD_COMPLETE) { return intent.getParcelableExtra(EXTRA_MANGA)?.manga diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt new file mode 100644 index 000000000..499f88f34 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt @@ -0,0 +1,30 @@ +package org.koitharu.kotatsu.download.ui.service + +import androidx.annotation.AnyThread +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first + +class PausingHandle { + + private val paused = MutableStateFlow(false) + + @get:AnyThread + val isPaused: Boolean + get() = paused.value + + @AnyThread + suspend fun awaitResumed() { + paused.filter { !it }.first() + } + + @AnyThread + fun pause() { + paused.value = true + } + + @AnyThread + fun resume() { + paused.value = false + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index 7cfa80db9..28f50e7de 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -6,16 +6,12 @@ import android.content.Context.ACTIVITY_SERVICE import android.content.SharedPreferences import android.content.pm.ResolveInfo import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkRequest import android.net.Uri -import android.os.Build import androidx.activity.result.ActivityResultLauncher import androidx.core.app.ActivityOptionsCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import androidx.work.CoroutineWorker -import kotlin.coroutines.resume import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.delay @@ -24,7 +20,6 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine val Context.connectivityManager: ConnectivityManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager @@ -32,28 +27,6 @@ val Context.connectivityManager: ConnectivityManager val Context.activityManager: ActivityManager? get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager -suspend fun ConnectivityManager.waitForNetwork(): Network { - val request = NetworkRequest.Builder().build() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - // fast path - activeNetwork?.let { return it } - } - return suspendCancellableCoroutine { cont -> - val callback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - unregisterNetworkCallback(this) - if (cont.isActive) { - cont.resume(network) - } - } - } - registerNetworkCallback(request, callback) - cont.invokeOnCancellation { - unregisterNetworkCallback(callback) - } - } -} - fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching { diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/PausingProgressJob.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/PausingProgressJob.kt new file mode 100644 index 000000000..e53806080 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/PausingProgressJob.kt @@ -0,0 +1,26 @@ +package org.koitharu.kotatsu.utils.progress + +import androidx.annotation.AnyThread +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.download.ui.service.PausingHandle + +class PausingProgressJob

( + job: Job, + progress: StateFlow

, + private val pausingHandle: PausingHandle, +) : ProgressJob

(job, progress) { + + @get:AnyThread + val isPaused: Boolean + get() = pausingHandle.isPaused + + @AnyThread + suspend fun awaitResumed() = pausingHandle.awaitResumed() + + @AnyThread + fun pause() = pausingHandle.pause() + + @AnyThread + fun resume() = pausingHandle.resume() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt index d401fc83a..919d952ab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt @@ -4,7 +4,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -class ProgressJob

( +open class ProgressJob

( private val job: Job, private val progress: StateFlow

, ) : Job by job { diff --git a/app/src/main/res/drawable/ic_restart_black.xml b/app/src/main/res/drawable/ic_restart_black.xml new file mode 100644 index 000000000..afa22f223 --- /dev/null +++ b/app/src/main/res/drawable/ic_restart_black.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file