Merge branch 'devel' into feature/nextgen

pull/189/head
Koitharu 4 years ago
commit 3866d126b7
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 419 versionCode 420
versionName '3.4.7' versionName '3.5.0'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -84,7 +84,7 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:7588617316') { implementation('com.github.KotatsuApp:kotatsu-parsers:85bfe42ddf') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }

@ -1,7 +1,6 @@
package org.koitharu.kotatsu.download.domain package org.koitharu.kotatsu.download.domain
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
@ -20,6 +19,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings 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.data.PagesCache
import org.koitharu.kotatsu.local.domain.CbzMangaOutput import org.koitharu.kotatsu.local.domain.CbzMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
@ -29,10 +29,9 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import org.koitharu.kotatsu.utils.progress.ProgressJob
private const val MAX_DOWNLOAD_ATTEMPTS = 3 private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 200L private const val SLOWDOWN_DELAY = 200L
@ -47,9 +46,6 @@ class DownloadManager @AssistedInject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
private val connectivityManager = context.getSystemService(
Context.CONNECTIVITY_SERVICE,
) as ConnectivityManager
private val coverWidth = context.resources.getDimensionPixelSize( private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width, androidx.core.R.dimen.compat_notification_large_icon_max_width,
) )
@ -62,18 +58,20 @@ class DownloadManager @AssistedInject constructor(
manga: Manga, manga: Manga,
chaptersIds: LongArray?, chaptersIds: LongArray?,
startId: Int, startId: Int,
): ProgressJob<DownloadState> { ): PausingProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>( val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null), DownloadState.Queued(startId = startId, manga = manga, cover = null),
) )
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId) val pausingHandle = PausingHandle()
return ProgressJob(job, stateFlow) val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
return PausingProgressJob(job, stateFlow, pausingHandle)
} }
private fun downloadMangaImpl( private fun downloadMangaImpl(
manga: Manga, manga: Manga,
chaptersIds: LongArray?, chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>, outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
startId: Int, startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
@Suppress("NAME_SHADOWING") @Suppress("NAME_SHADOWING")
@ -113,36 +111,24 @@ class DownloadManager @AssistedInject constructor(
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga" "${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
} }
for ((chapterIndex, chapter) in chapters.withIndex()) { 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()) { for ((pageIndex, page) in pages.withIndex()) {
var retryCounter = 0 runFailsafe(outState, pausingHandle) {
failsafe@ while (true) { val url = repo.getPageUrl(page)
try { val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
val url = repo.getPageUrl(page) output.addPage(
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName) chapter = chapter,
output.addPage( file = file,
chapter = chapter, pageNumber = pageIndex,
file = file, ext = MimeTypeMap.getFileExtensionFromUrl(url),
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
}
}
} }
outState.value = DownloadState.Progress( outState.value = DownloadState.Progress(
startId, startId = startId,
data, manga = data,
cover, cover = cover,
totalChapters = chapters.size, totalChapters = chapters.size,
currentChapter = chapterIndex, currentChapter = chapterIndex,
totalPages = pages.size, totalPages = pages.size,
@ -164,15 +150,40 @@ class DownloadManager @AssistedInject constructor(
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e) outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {
output?.cleanup() output?.cleanup()
File(destination, tempFileName).deleteAwait() File(destination, tempFileName).deleteAwait()
coroutineContext[WakeLockNode]?.release()
semaphore.release()
localMangaRepository.unlockManga(manga.id)
}
}
}
private suspend fun <R> runFailsafe(
outState: MutableStateFlow<DownloadState>,
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)
} }
} }
@ -202,6 +213,7 @@ class DownloadManager @AssistedInject constructor(
manga = prevValue.manga, manga = prevValue.manga,
cover = prevValue.cover, cover = prevValue.cover,
error = throwable, error = throwable,
canRetry = false,
) )
} }

@ -108,6 +108,7 @@ sealed interface DownloadState {
} }
} }
@Deprecated("TODO: remove")
class WaitingForNetwork( class WaitingForNetwork(
override val startId: Int, override val startId: Int,
override val manga: Manga, override val manga: Manga,
@ -170,6 +171,7 @@ sealed interface DownloadState {
override val manga: Manga, override val manga: Manga,
override val cover: Drawable?, override val cover: Drawable?,
val error: Throwable, val error: Throwable,
val canRetry: Boolean,
) : DownloadState { ) : DownloadState {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -182,6 +184,7 @@ sealed interface DownloadState {
if (manga != other.manga) return false if (manga != other.manga) return false
if (cover != other.cover) return false if (cover != other.cover) return false
if (error != other.error) return false if (error != other.error) return false
if (canRetry != other.canRetry) return false
return true return true
} }
@ -191,6 +194,7 @@ sealed interface DownloadState {
result = 31 * result + manga.hashCode() result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0) result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + error.hashCode() result = 31 * result + error.hashCode()
result = 31 * result + canRetry.hashCode()
return result return result
} }
} }

@ -29,16 +29,26 @@ class DownloadNotification(private val context: Context, startId: Int) {
context.getString(android.R.string.cancel), context.getString(android.R.string.cancel),
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
context, context,
startId, startId * 2,
DownloadService.getCancelIntent(startId), DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, 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( private val listIntent = PendingIntent.getActivity(
context, context,
REQUEST_LIST, REQUEST_LIST,
DownloadsActivity.newIntent(context), DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE, PendingIntentCompat.FLAG_IMMUTABLE
) )
init { init {
@ -89,10 +99,14 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setSmallIcon(android.R.drawable.stat_notify_error) builder.setSmallIcon(android.R.drawable.stat_notify_error)
builder.setSubText(context.getString(R.string.error)) builder.setSubText(context.getString(R.string.error))
builder.setContentText(message) builder.setContentText(message)
builder.setAutoCancel(true) builder.setAutoCancel(!state.canRetry)
builder.setOngoing(false) builder.setOngoing(state.canRetry)
builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
if (state.canRetry) {
builder.addAction(cancelAction)
builder.addAction(retryAction)
}
} }
is DownloadState.PostProcessing -> { is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)

@ -27,6 +27,7 @@ import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.domain.WakeLockNode import org.koitharu.kotatsu.download.domain.WakeLockNode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.throttle 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.ProgressJob
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
@ -39,7 +40,7 @@ class DownloadService : BaseService() {
@Inject @Inject
lateinit var downloadManagerFactory: DownloadManager.Factory lateinit var downloadManagerFactory: DownloadManager.Factory
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadState>>() private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>()
private val jobCount = MutableStateFlow(0) private val jobCount = MutableStateFlow(0)
private val controlReceiver = ControlReceiver() private val controlReceiver = ControlReceiver()
private var binder: DownloadBinder? = null private var binder: DownloadBinder? = null
@ -54,7 +55,10 @@ class DownloadService : BaseService() {
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
) )
DownloadNotification.createChannel(this) 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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -92,7 +96,7 @@ class DownloadService : BaseService() {
startId: Int, startId: Int,
manga: Manga, manga: Manga,
chaptersIds: LongArray?, chaptersIds: LongArray?,
): ProgressJob<DownloadState> { ): PausingProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId) val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job) listenJob(job)
return job return job
@ -146,7 +150,7 @@ class DownloadService : BaseService() {
} }
private val DownloadState.isTerminal: Boolean 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() { inner class ControlReceiver : BroadcastReceiver() {
@ -157,6 +161,10 @@ class DownloadService : BaseService() {
jobs.remove(cancelId)?.cancel() jobs.remove(cancelId)?.cancel()
jobCount.value = jobs.size jobCount.value = jobs.size
} }
ACTION_DOWNLOAD_RESUME -> {
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
jobs[cancelId]?.resume()
}
} }
} }
} }
@ -175,6 +183,7 @@ class DownloadService : BaseService() {
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE" 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_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_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids" private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
@ -217,6 +226,9 @@ class DownloadService : BaseService() {
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
.putExtra(EXTRA_CANCEL_ID, startId) .putExtra(EXTRA_CANCEL_ID, startId)
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
.putExtra(EXTRA_CANCEL_ID, startId)
fun getDownloadedManga(intent: Intent?): Manga? { fun getDownloadedManga(intent: Intent?): Manga? {
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) { if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga

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

@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -61,7 +62,11 @@ class PageSaveHelper @Inject constructor(
} != null } != null
private suspend fun getProposedFileName(url: String, file: File): String { 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('.', "") var extension = name.substringAfterLast('.', "")
name = name.substringBeforeLast('.') name = name.substringBeforeLast('.')
if (extension.length !in 2..4) { if (extension.length !in 2..4) {

@ -7,6 +7,8 @@ import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
@ -26,6 +28,8 @@ open class PageHolder(
View.OnClickListener { View.OnClickListener {
init { init {
binding.ssiv.setExecutor(Dispatchers.Default.asExecutor())
binding.ssiv.setEagerLoadingEnabled(!isLowRamDevice(context))
binding.ssiv.setOnImageEventListener(delegate) binding.ssiv.setOnImageEventListener(delegate)
@Suppress("LeakingThis") @Suppress("LeakingThis")
bindingInfo.buttonRetry.setOnClickListener(this) bindingInfo.buttonRetry.setOnClickListener(this)

@ -3,13 +3,16 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.AttrRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
class WebtoonFrameLayout @JvmOverloads constructor( 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) { ) : FrameLayout(context, attrs, defStyleAttr) {
private val target by lazy { private val target by lazy(LazyThreadSafetyMode.NONE) {
findViewById<WebtoonImageView>(R.id.ssiv) findViewById<WebtoonImageView>(R.id.ssiv)
} }

@ -13,14 +13,14 @@ import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
class WebtoonHolder( class WebtoonHolder(
binding: ItemPageWebtoonBinding, binding: ItemPageWebtoonBinding,
loader: PageLoader, loader: PageLoader,
settings: AppSettings, settings: AppSettings,
exceptionResolver: ExceptionResolver exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver), ) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver),
View.OnClickListener { View.OnClickListener {
@ -29,6 +29,7 @@ class WebtoonHolder(
init { init {
binding.ssiv.setOnImageEventListener(delegate) binding.ssiv.setOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this) bindingInfo.buttonRetry.setOnClickListener(this)
GoneOnInvisibleListener(bindingInfo.progressBar).attach()
} }
override fun onBind(data: ReaderPage) { override fun onBind(data: ReaderPage) {
@ -61,9 +62,9 @@ class WebtoonHolder(
override fun onImageShowing(zoom: ZoomMode) { override fun onImageShowing(zoom: ZoomMode) {
with(binding.ssiv) { with(binding.ssiv) {
maxScale = 2f * width / sWidth.toFloat()
setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM)
minScale = width / sWidth.toFloat() minScale = width / sWidth.toFloat()
maxScale = minScale
scrollTo( scrollTo(
when { when {
scrollToRestore != 0 -> scrollToRestore scrollToRestore != 0 -> scrollToRestore

@ -1,11 +1,15 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.app.Activity
import android.content.Context import android.content.Context
import android.graphics.PointF import android.graphics.PointF
import android.util.AttributeSet import android.util.AttributeSet
import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView 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.parsers.util.toIntUp
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.ext.parents
private const val SCROLL_UNKNOWN = -1 private const val SCROLL_UNKNOWN = -1
@ -15,15 +19,15 @@ class WebtoonImageView @JvmOverloads constructor(
) : SubsamplingScaleImageView(context, attr) { ) : SubsamplingScaleImageView(context, attr) {
private val ct = PointF() 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 scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN private var scrollRange = SCROLL_UNKNOWN
init {
setExecutor(Dispatchers.Default.asExecutor())
setEagerLoadingEnabled(!isLowRamDevice(context))
}
fun scrollBy(delta: Int) { fun scrollBy(delta: Int) {
val maxScroll = getScrollRange() val maxScroll = getScrollRange()
if (maxScroll == 0) { if (maxScroll == 0) {
@ -36,6 +40,7 @@ class WebtoonImageView @JvmOverloads constructor(
fun scrollTo(y: Int) { fun scrollTo(y: Int) {
val maxScroll = getScrollRange() val maxScroll = getScrollRange()
if (maxScroll == 0) { if (maxScroll == 0) {
resetScaleAndCenter()
return return
} }
scrollToInternal(y.coerceIn(0, maxScroll)) scrollToInternal(y.coerceIn(0, maxScroll))
@ -58,8 +63,11 @@ class WebtoonImageView @JvmOverloads constructor(
override fun getSuggestedMinimumHeight(): Int { override fun getSuggestedMinimumHeight(): Int {
var desiredHeight = super.getSuggestedMinimumHeight() var desiredHeight = super.getSuggestedMinimumHeight()
if (sHeight == 0 && desiredHeight < displayHeight) { if (sHeight == 0) {
desiredHeight = displayHeight val parentHeight = parentHeight()
if (desiredHeight < parentHeight) {
desiredHeight = parentHeight
}
} }
return desiredHeight return desiredHeight
} }
@ -84,7 +92,7 @@ class WebtoonImageView @JvmOverloads constructor(
} }
} }
width = width.coerceAtLeast(suggestedMinimumWidth) width = width.coerceAtLeast(suggestedMinimumWidth)
height = height.coerceIn(suggestedMinimumHeight, displayHeight) height = height.coerceIn(suggestedMinimumHeight, parentHeight())
setMeasuredDimension(width, height) setMeasuredDimension(width, height)
} }
@ -101,4 +109,8 @@ class WebtoonImageView @JvmOverloads constructor(
val totalHeight = (sHeight * minScale).toIntUp() val totalHeight = (sHeight * minScale).toIntUp()
scrollRange = (totalHeight - height).coerceAtLeast(0) scrollRange = (totalHeight - height).coerceAtLeast(0)
} }
private fun parentHeight(): Int {
return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
}
} }

@ -6,6 +6,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.math.sign import kotlin.math.sign
@Suppress("unused")
class WebtoonLayoutManager : LinearLayoutManager { class WebtoonLayoutManager : LinearLayoutManager {
private var scrollDirection: Int = 0 private var scrollDirection: Int = 0

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale
import coil.size.Size import coil.size.Size
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@ -23,14 +24,13 @@ fun pageThumbnailAD(
loader: PageLoader, loader: PageLoader,
clickListener: OnListItemClickListener<MangaPage>, clickListener: OnListItemClickListener<MangaPage>,
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>( ) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) },
) { ) {
var job: Job? = null var job: Job? = null
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
val thumbSize = Size( val thumbSize = Size(
width = gridWidth, width = gridWidth,
height = (gridWidth * 13f / 18f).toInt() height = (gridWidth * 13f / 18f).toInt(),
) )
suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) { suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) {
@ -40,8 +40,9 @@ fun pageThumbnailAD(
.data(url) .data(url)
.referer(item.page.referer) .referer(item.page.referer)
.size(thumbSize) .size(thumbSize)
.allowRgb565(isLowRamDevice(context)) .scale(Scale.FILL)
.build() .allowRgb565(true)
.build(),
).drawable ).drawable
}?.let { drawable -> }?.let { drawable ->
return@withContext drawable return@withContext drawable
@ -52,7 +53,7 @@ fun pageThumbnailAD(
.data(file) .data(file)
.size(thumbSize) .size(thumbSize)
.allowRgb565(isLowRamDevice(context)) .allowRgb565(isLowRamDevice(context))
.build() .build(),
).drawable ).drawable
} }

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

@ -11,8 +11,6 @@ import android.content.pm.ResolveInfo
import android.database.SQLException import android.database.SQLException
import android.graphics.Color import android.graphics.Color
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
@ -29,7 +27,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider import com.google.android.material.elevation.ElevationOverlayProvider
import kotlin.coroutines.resume
import kotlin.math.roundToLong import kotlin.math.roundToLong
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
@ -39,7 +36,6 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import okio.IOException import okio.IOException
import org.json.JSONException import org.json.JSONException
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
@ -51,28 +47,6 @@ val Context.activityManager: ActivityManager?
val Context.connectivityManager: ConnectivityManager val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
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) fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching { suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {

@ -160,8 +160,6 @@ fun RecyclerView.invalidateNestedItemDecorations() {
} }
} }
internal val View.compatPaddingStart get() = ViewCompat.getPaddingStart(this)
val View.parents: Sequence<ViewParent> val View.parents: Sequence<ViewParent>
get() = sequence { get() = sequence {
var p: ViewParent? = parent var p: ViewParent? = parent

@ -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<P>(
job: Job,
progress: StateFlow<P>,
private val pausingHandle: PausingHandle,
) : ProgressJob<P>(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()
}

@ -4,7 +4,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class ProgressJob<P>( open class ProgressJob<P>(
private val job: Job, private val job: Job,
private val progress: StateFlow<P>, private val progress: StateFlow<P>,
) : Job by job { ) : Job by job {

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M12,4C14.1,4 16.1,4.8 17.6,6.3C20.7,9.4 20.7,14.5 17.6,17.6C15.8,19.5 13.3,20.2 10.9,19.9L11.4,17.9C13.1,18.1 14.9,17.5 16.2,16.2C18.5,13.9 18.5,10.1 16.2,7.7C15.1,6.6 13.5,6 12,6V10.6L7,5.6L12,0.6V4M6.3,17.6C3.7,15 3.3,11 5.1,7.9L6.6,9.4C5.5,11.6 5.9,14.4 7.8,16.2C8.3,16.7 8.9,17.1 9.6,17.4L9,19.4C8,19 7.1,18.4 6.3,17.6Z" />
</vector>
Loading…
Cancel
Save