From d42cd598802a39b7b5e05a1d0a5f8bd482a838d4 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Wed, 27 Jul 2022 23:24:41 +0300 Subject: [PATCH 1/6] Fix enabling disabled new sources Co-authored-by: Koitharu <8948226+nv95@users.noreply.github.com> --- .../settings/newsources/NewSourcesViewModel.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt index 06567642b..a0e46cd2f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt @@ -33,15 +33,22 @@ class NewSourcesViewModel( private fun buildList() { val locales = LocaleListCompat.getDefault().mapToSet { it.language } - val hidden = settings.hiddenSources + val pendingHidden = HashSet() sources.value = initialList.map { val locale = it.locale + val isEnabledByLocale = locale == null || locale in locales + if (!isEnabledByLocale) { + pendingHidden += it.name + } SourceConfigItem.SourceItem( source = it, summary = it.getLocaleTitle(), - isEnabled = it.name !in hidden && (locale == null || locale in locales), - isDraggable = false, + isEnabled = isEnabledByLocale, + isDraggable = false ) } + if (pendingHidden.isNotEmpty()) { + settings.hiddenSources += pendingHidden + } } } \ No newline at end of file From d6781e1d14a89908d63c73d442845f2d8d620c5f Mon Sep 17 00:00:00 2001 From: Koitharu Date: Fri, 29 Jul 2022 15:20:54 +0300 Subject: [PATCH 2/6] Yet another attempt to make webtoon reader great again --- .../reader/ui/pager/standard/PageHolder.kt | 4 +++ .../ui/pager/webtoon/WebtoonFrameLayout.kt | 7 +++-- .../reader/ui/pager/webtoon/WebtoonHolder.kt | 7 +++-- .../ui/pager/webtoon/WebtoonImageView.kt | 30 +++++++++++++------ .../ui/pager/webtoon/WebtoonLayoutManager.kt | 1 + .../ui/pager/webtoon/WebtoonReaderFragment.kt | 2 +- .../ui/thumbnails/adapter/PageThumbnailAD.kt | 3 +- .../kotatsu/utils/GoneOnInvisibleListener.kt | 23 ++++++++++++++ .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 9 ++++++ .../org/koitharu/kotatsu/utils/ext/ViewExt.kt | 12 +++++++- 10 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/GoneOnInvisibleListener.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index 58aba0ee6..a4d60c861 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -7,6 +7,8 @@ import android.view.View import androidx.core.view.isVisible import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.ZoomMode @@ -26,6 +28,8 @@ open class PageHolder( View.OnClickListener { init { + binding.ssiv.setExecutor(Dispatchers.Default.asExecutor()) + binding.ssiv.setEagerLoadingEnabled(!isLowRamDevice(context)) binding.ssiv.setOnImageEventListener(delegate) @Suppress("LeakingThis") bindingInfo.buttonRetry.setOnClickListener(this) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt index 2fe267156..fd1e45290 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt @@ -3,13 +3,16 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout +import androidx.annotation.AttrRes import org.koitharu.kotatsu.R class WebtoonFrameLayout @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, ) : FrameLayout(context, attrs, defStyleAttr) { - private val target by lazy { + private val target by lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.ssiv) } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index cc4370748..4d9fc1ed8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -13,14 +13,14 @@ import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import org.koitharu.kotatsu.utils.GoneOnInvisibleListener import org.koitharu.kotatsu.utils.ext.* - class WebtoonHolder( binding: ItemPageWebtoonBinding, loader: PageLoader, settings: AppSettings, - exceptionResolver: ExceptionResolver + exceptionResolver: ExceptionResolver, ) : BasePageHolder(binding, loader, settings, exceptionResolver), View.OnClickListener { @@ -29,6 +29,7 @@ class WebtoonHolder( init { binding.ssiv.setOnImageEventListener(delegate) bindingInfo.buttonRetry.setOnClickListener(this) + GoneOnInvisibleListener(bindingInfo.progressBar).attach() } override fun onBind(data: ReaderPage) { @@ -61,9 +62,9 @@ class WebtoonHolder( override fun onImageShowing(zoom: ZoomMode) { with(binding.ssiv) { - maxScale = 2f * width / sWidth.toFloat() setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) minScale = width / sWidth.toFloat() + maxScale = minScale scrollTo( when { scrollToRestore != 0 -> scrollToRestore diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt index 31396bd06..3404d2911 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt @@ -1,11 +1,15 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon -import android.app.Activity import android.content.Context import android.graphics.PointF import android.util.AttributeSet +import androidx.recyclerview.widget.RecyclerView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor import org.koitharu.kotatsu.parsers.util.toIntUp +import org.koitharu.kotatsu.utils.ext.isLowRamDevice +import org.koitharu.kotatsu.utils.ext.parents private const val SCROLL_UNKNOWN = -1 @@ -15,15 +19,15 @@ class WebtoonImageView @JvmOverloads constructor( ) : SubsamplingScaleImageView(context, attr) { private val ct = PointF() - private val displayHeight = if (context is Activity) { - context.window.decorView.height - } else { - context.resources.displayMetrics.heightPixels - } private var scrollPos = 0 private var scrollRange = SCROLL_UNKNOWN + init { + setExecutor(Dispatchers.Default.asExecutor()) + setEagerLoadingEnabled(!isLowRamDevice(context)) + } + fun scrollBy(delta: Int) { val maxScroll = getScrollRange() if (maxScroll == 0) { @@ -36,6 +40,7 @@ class WebtoonImageView @JvmOverloads constructor( fun scrollTo(y: Int) { val maxScroll = getScrollRange() if (maxScroll == 0) { + resetScaleAndCenter() return } scrollToInternal(y.coerceIn(0, maxScroll)) @@ -58,8 +63,11 @@ class WebtoonImageView @JvmOverloads constructor( override fun getSuggestedMinimumHeight(): Int { var desiredHeight = super.getSuggestedMinimumHeight() - if (sHeight == 0 && desiredHeight < displayHeight) { - desiredHeight = displayHeight + if (sHeight == 0) { + val parentHeight = parentHeight() + if (desiredHeight < parentHeight) { + desiredHeight = parentHeight + } } return desiredHeight } @@ -84,7 +92,7 @@ class WebtoonImageView @JvmOverloads constructor( } } width = width.coerceAtLeast(suggestedMinimumWidth) - height = height.coerceIn(suggestedMinimumHeight, displayHeight) + height = height.coerceIn(suggestedMinimumHeight, parentHeight()) setMeasuredDimension(width, height) } @@ -101,4 +109,8 @@ class WebtoonImageView @JvmOverloads constructor( val totalHeight = (sHeight * minScale).toIntUp() scrollRange = (totalHeight - height).coerceAtLeast(0) } + + private fun parentHeight(): Int { + return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0 + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt index 29f21d08e..067ada3ac 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt @@ -6,6 +6,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.sign +@Suppress("unused") class WebtoonLayoutManager : LinearLayoutManager { private var scrollDirection: Int = 0 diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt index d0c25311d..751a82808 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt @@ -23,7 +23,7 @@ class WebtoonReaderFragment : BaseReader() { override fun onInflateView( inflater: LayoutInflater, - container: ViewGroup? + container: ViewGroup?, ) = FragmentReaderWebtoonBinding.inflate(inflater, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt index 8cddae963..42529810b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.thumbnails.adapter import android.graphics.drawable.Drawable import coil.ImageLoader import coil.request.ImageRequest +import coil.size.Scale import coil.size.Size import com.google.android.material.R as materialR import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding @@ -24,7 +25,6 @@ fun pageThumbnailAD( ) = adapterDelegateViewBinding( { inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) } ) { - var job: Job? = null val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) val thumbSize = Size( @@ -39,6 +39,7 @@ fun pageThumbnailAD( .data(url) .referer(item.page.referer) .size(thumbSize) + .scale(Scale.FILL) .allowRgb565(true) .build() ).drawable diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/GoneOnInvisibleListener.kt b/app/src/main/java/org/koitharu/kotatsu/utils/GoneOnInvisibleListener.kt new file mode 100644 index 000000000..da4fe3f3f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/GoneOnInvisibleListener.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.utils + +import android.view.View +import android.view.ViewTreeObserver + +/** + * ProgressIndicator become INVISIBLE instead of GONE by hide() call. + * It`s final so we need this workaround + */ +class GoneOnInvisibleListener( + private val view: View, +) : ViewTreeObserver.OnGlobalLayoutListener { + + override fun onGlobalLayout() { + if (view.visibility == View.INVISIBLE) { + view.visibility = View.GONE + } + } + + fun attach() { + view.viewTreeObserver.addOnGlobalLayoutListener(this) + } +} \ 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 a7320ad43..7cfa80db9 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 @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.utils.ext +import android.app.ActivityManager import android.content.Context +import android.content.Context.ACTIVITY_SERVICE import android.content.SharedPreferences import android.content.pm.ResolveInfo import android.net.ConnectivityManager @@ -27,6 +29,9 @@ import kotlinx.coroutines.suspendCancellableCoroutine val Context.connectivityManager: ConnectivityManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as 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) { @@ -92,4 +97,8 @@ fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) { delay(delay) runnable.run() } +} + +fun isLowRamDevice(context: Context): Boolean { + return context.activityManager?.isLowRamDevice ?: false } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt index bfd3959a9..18fa66cc2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.graphics.Rect import android.view.View import android.view.ViewGroup +import android.view.ViewParent import android.view.inputmethod.InputMethodManager import androidx.core.view.children import androidx.recyclerview.widget.LinearLayoutManager @@ -138,4 +139,13 @@ fun ViewGroup.findViewsByType(clazz: Class): Sequence { } } } -} \ No newline at end of file +} + +val View.parents: Sequence + get() = sequence { + var p: ViewParent? = parent + while (p != null) { + yield(p) + p = p.parent + } + } \ No newline at end of file From 020df5c1f7f5052d3d8e5bb1d10a81e6c37daea0 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 30 Jul 2022 14:10:18 +0300 Subject: [PATCH 3/6] Fix saving pages from cbz --- .../org/koitharu/kotatsu/reader/ReaderModule.kt | 4 ++-- .../koitharu/kotatsu/reader/ui/PageSaveHelper.kt | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt index 5b8faf203..a3fb71bdb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt @@ -14,7 +14,7 @@ val readerModule factory { MangaDataRepository(get()) } single { PagesCache(get()) } - factory { PageSaveHelper(get(), androidContext()) } + factory { PageSaveHelper(androidContext()) } viewModel { params -> ReaderViewModel( @@ -25,7 +25,7 @@ val readerModule historyRepository = get(), settings = get(), pageSaveHelper = get(), - bookmarksRepository = get(), + bookmarksRepository = get() ) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index 3e19c7036..e021723af 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -4,6 +4,10 @@ import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import androidx.activity.result.ActivityResultLauncher +import androidx.core.net.toUri +import java.io.File +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine @@ -11,19 +15,14 @@ import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrl import okio.IOException import org.koitharu.kotatsu.base.domain.MangaUtils -import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.reader.domain.PageLoader -import java.io.File -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume private const val MAX_FILENAME_LENGTH = 10 private const val EXTENSION_FALLBACK = "png" class PageSaveHelper( - private val cache: PagesCache, context: Context, ) { @@ -61,7 +60,11 @@ class PageSaveHelper( } != null private suspend fun getProposedFileName(url: String, file: File): String { - var name = url.toHttpUrl().pathSegments.last() + var name = if (url.startsWith("cbz://")) { + requireNotNull(url.toUri().fragment) + } else { + url.toHttpUrl().pathSegments.last() + } var extension = name.substringAfterLast('.', "") name = name.substringBeforeLast('.') if (extension.length !in 2..4) { From bcc2f531c3a96a4225f5ba34d9f7f5fce4cd7e88 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 30 Jul 2022 15:51:21 +0300 Subject: [PATCH 4/6] Ability to resume download after IOException --- .../download/domain/DownloadManager.kt | 101 ++++++++++-------- .../kotatsu/download/domain/DownloadState.kt | 4 + .../ui/service/DownloadNotification.kt | 22 +++- .../download/ui/service/DownloadService.kt | 24 +++-- .../download/ui/service/PausingHandle.kt | 30 ++++++ .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 27 ----- .../utils/progress/PausingProgressJob.kt | 26 +++++ .../kotatsu/utils/progress/ProgressJob.kt | 2 +- .../main/res/drawable/ic_restart_black.xml | 8 ++ 9 files changed, 163 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/service/PausingHandle.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/progress/PausingProgressJob.kt create mode 100644 app/src/main/res/drawable/ic_restart_black.xml 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 From 659c327a6d77c58cf49a908234a0b7068ca72d78 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 30 Jul 2022 16:05:01 +0300 Subject: [PATCH 5/6] Update parsers and version --- app/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c7090357b..f32d10554 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,8 +14,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 32 - versionCode 419 - versionName '3.4.7' + versionCode 420 + versionName '3.5.0' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -79,7 +79,7 @@ afterEvaluate { } } dependencies { - implementation('com.github.nv95:kotatsu-parsers:7588617316') { + implementation('com.github.KotatsuApp:kotatsu-parsers:dac06ddd0b') { exclude group: 'org.json', module: 'json' } From 8faacab53ad2197c38f6d63b3439bf50f03816c4 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 30 Jul 2022 16:10:03 +0300 Subject: [PATCH 6/6] Fix github url --- app/src/main/res/values/constants.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 98011f83d..2691718af 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -1,6 +1,6 @@ - https://github.com/nv95/Kotatsu + https://github.com/KotatsuApp/Kotatsu https://discord.gg/NNJ5RgVBC5 https://4pda.to/forum/index.php?showtopic=697669 https://twitter.com/kotatsuapp