diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 7509c5cae..c6dfdb5c6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.prefs import android.content.Context import android.content.SharedPreferences +import android.net.ConnectivityManager import android.net.Uri import android.os.Build import android.provider.Settings @@ -147,6 +148,14 @@ class AppSettings(context: Context) { val isSuggestionsExcludeNsfw: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) + fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean { + return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { + NETWORK_ALWAYS -> true + NETWORK_NEVER -> false + else -> cm.isActiveNetworkMetered + } + } + fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat = when (format) { "" -> DateFormat.getDateInstance(DateFormat.SHORT) @@ -237,6 +246,7 @@ class AppSettings(context: Context) { const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" + const val KEY_PAGES_PRELOAD = "pages_preload" const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" @@ -250,6 +260,10 @@ class AppSettings(context: Context) { const val KEY_FEEDBACK_GITHUB = "about_feedback_github" const val KEY_SUPPORT_DEVELOPER = "about_support_developer" + private const val NETWORK_NEVER = 0 + private const val NETWORK_ALWAYS = 1 + private const val NETWORK_NON_METERED = 2 + val isDynamicColorAvailable: Boolean get() = DynamicColors.isDynamicColorAvailable() || (isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt index 20724d769..e854a23b8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt @@ -10,13 +10,13 @@ import kotlinx.coroutines.flow.onEach import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.download.domain.DownloadManager -import org.koitharu.kotatsu.utils.JobStateFlow import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.progress.ProgressJob fun downloadItemAD( scope: CoroutineScope, coil: ImageLoader, -) = adapterDelegateViewBinding, JobStateFlow, ItemDownloadBinding>( +) = adapterDelegateViewBinding, ProgressJob, ItemDownloadBinding>( { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) } ) { @@ -24,7 +24,7 @@ fun downloadItemAD( bind { job?.cancel() - job = item.onFirst { state -> + job = item.progressAsFlow().onFirst { state -> binding.imageViewCover.newImageRequest(state.manga.coverUrl) .referer(state.manga.publicUrl) .placeholder(state.cover) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt index 325180a79..37df8b9d8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt @@ -5,12 +5,12 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import kotlinx.coroutines.CoroutineScope import org.koitharu.kotatsu.download.domain.DownloadManager -import org.koitharu.kotatsu.utils.JobStateFlow +import org.koitharu.kotatsu.utils.progress.ProgressJob class DownloadsAdapter( scope: CoroutineScope, coil: ImageLoader, -) : AsyncListDifferDelegationAdapter>(DiffCallback()) { +) : AsyncListDifferDelegationAdapter>(DiffCallback()) { init { delegatesManager.addDelegate(downloadItemAD(scope, coil)) @@ -18,23 +18,23 @@ class DownloadsAdapter( } override fun getItemId(position: Int): Long { - return items[position].value.startId.toLong() + return items[position].progressValue.startId.toLong() } - private class DiffCallback : DiffUtil.ItemCallback>() { + private class DiffCallback : DiffUtil.ItemCallback>() { override fun areItemsTheSame( - oldItem: JobStateFlow, - newItem: JobStateFlow, + oldItem: ProgressJob, + newItem: ProgressJob, ): Boolean { - return oldItem.value.startId == newItem.value.startId + return oldItem.progressValue.startId == newItem.progressValue.startId } override fun areContentsTheSame( - oldItem: JobStateFlow, - newItem: JobStateFlow, + oldItem: ProgressJob, + newItem: ProgressJob, ): Boolean { - return oldItem.value == newItem.value + return oldItem.progressValue == newItem.progressValue } } } \ No newline at end of file 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 f5681df51..5d62833fc 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 @@ -4,7 +4,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.net.ConnectivityManager import android.os.Binder import android.os.IBinder import android.os.PowerManager @@ -31,8 +30,9 @@ import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.download.domain.DownloadManager -import org.koitharu.kotatsu.utils.JobStateFlow +import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.toArraySet +import org.koitharu.kotatsu.utils.progress.ProgressJob import java.util.concurrent.TimeUnit import kotlin.collections.set @@ -42,7 +42,7 @@ class DownloadService : BaseService() { private lateinit var wakeLock: PowerManager.WakeLock private lateinit var downloadManager: DownloadManager - private val jobs = LinkedHashMap>() + private val jobs = LinkedHashMap>() private val jobCount = MutableStateFlow(0) private val mutex = Mutex() private val controlReceiver = ControlReceiver() @@ -93,7 +93,7 @@ class DownloadService : BaseService() { startId: Int, manga: Manga, chaptersIds: Set?, - ): JobStateFlow { + ): ProgressJob { val initialState = DownloadManager.State.Queued(startId, manga, null) val stateFlow = MutableStateFlow(initialState) val job = lifecycleScope.launch { @@ -131,7 +131,7 @@ class DownloadService : BaseService() { } } } - return JobStateFlow(stateFlow, job) + return ProgressJob(job, stateFlow) } inner class ControlReceiver : BroadcastReceiver() { @@ -149,7 +149,7 @@ class DownloadService : BaseService() { class DownloadBinder(private val service: DownloadService) : Binder() { - val downloads: Flow>> + val downloads: Flow>> get() = service.jobCount.mapLatest { service.jobs.values } } @@ -183,9 +183,8 @@ class DownloadService : BaseService() { .putExtra(ACTION_DOWNLOAD_CANCEL, startId) private fun confirmDataTransfer(context: Context, callback: () -> Unit) { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val settings = GlobalContext.get().get() - if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) { + if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) { CheckBoxAlertDialog.Builder(context) .setTitle(R.string.warning) .setMessage(R.string.network_consumption_warning) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt index ceed3cd24..dd0782493 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data import android.content.Context import com.tomclaw.cache.DiskLruCache +import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.subdir @@ -30,4 +31,33 @@ class PagesCache(context: Context) { file.delete() return res } + + fun put( + url: String, + inputStream: InputStream, + contentLength: Long, + progress: MutableStateFlow, + ): File { + val file = File(cacheDir, url.longHashCode().toString()) + file.outputStream().use { out -> + var bytesCopied: Long = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + publishProgress(contentLength, bytesCopied, progress) + bytes = inputStream.read(buffer) + } + } + val res = lruCache.put(url, file) + file.delete() + return res + } + + private fun publishProgress(contentLength: Long, bytesCopied: Long, progress: MutableStateFlow) { + if (contentLength > 0) { + progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat() + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 6eb29088c..fc87ba5d6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -1,94 +1,107 @@ package org.koitharu.kotatsu.reader.domain +import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import androidx.collection.LongSparseArray import androidx.collection.set import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient import okhttp3.Request +import okio.Closeable import org.koin.core.component.KoinComponent +import org.koin.core.component.get import org.koitharu.kotatsu.core.model.MangaPage +import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.utils.ext.await +import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf +import org.koitharu.kotatsu.utils.progress.ProgressDeferred import java.io.File +import java.util.* +import java.util.concurrent.atomic.AtomicInteger import java.util.zip.ZipFile -class PageLoader( - scope: CoroutineScope, - private val okHttp: OkHttpClient, - private val cache: PagesCache -) : CoroutineScope by scope, KoinComponent { +private const val PROGRESS_UNDEFINED = -1f +private const val PREFETCH_LIMIT_DEFAULT = 10 - private var repository: MangaRepository? = null - private val tasks = LongSparseArray>() +class PageLoader : KoinComponent, Closeable { + + val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val okHttp = get() + private val cache = get() + private val settings = get() + private val connectivityManager = get().connectivityManager + private val tasks = LongSparseArray>() private val convertLock = Mutex() + private var repository: MangaRepository? = null + private var prefetchQueue = LinkedList() + private val counter = AtomicInteger(0) + private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive + private val emptyProgressFlow: StateFlow = MutableStateFlow(-1f) - suspend fun loadPage(page: MangaPage, force: Boolean): File { + override fun close() { + loaderScope.cancel() + tasks.clear() + } + + fun isPrefetchApplicable(): Boolean { + return repository is RemoteMangaRepository && settings.isPagesPreloadAllowed(connectivityManager) + } + + fun prefetch(pages: List) { + synchronized(prefetchQueue) { + for (page in pages.asReversed()) { + if (tasks.containsKey(page.id)) { + continue + } + prefetchQueue.offerFirst(page.toMangaPage()) + if (prefetchQueue.size > prefetchQueueLimit) { + prefetchQueue.pollLast() + } + } + } + if (counter.get() == 0) { + onIdle() + } + } + + fun loadPageAsync(page: MangaPage, force: Boolean) : ProgressDeferred { if (!force) { cache[page.url]?.let { - return it + return getCompletedTask(it) } } var task = tasks[page.id] if (force) { task?.cancel() } else if (task?.isCancelled == false) { - return task.await() + return task } - task = loadAsync(page) + task = loadPageAsyncImpl(page) tasks[page.id] = task - return task.await() + return task } - private fun loadAsync(page: MangaPage): Deferred { - var repo = repository - if (repo?.source != page.source) { - repo = mangaRepositoryOf(page.source) - repository = repo - } - return async(Dispatchers.IO) { - val pageUrl = repo.getPageUrl(page) - check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } - val uri = Uri.parse(pageUrl) - if (uri.scheme == "cbz") { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - zip.getInputStream(entry).use { - cache.put(pageUrl, it) - } - } else { - val request = Request.Builder() - .url(pageUrl) - .get() - .header(CommonHeaders.REFERER, page.referer) - .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") - .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) - .build() - okHttp.newCall(request).await().use { response -> - check(response.isSuccessful) { - "Invalid response: ${response.code} ${response.message}" - } - val body = checkNotNull(response.body) { - "Null response" - } - body.byteStream().use { - cache.put(pageUrl, it) - } - } - } - } + suspend fun loadPage(page: MangaPage, force: Boolean): File { + return loadPageAsync(page, force).await() } suspend fun convertInPlace(file: File) { - convertLock.withLock(Lock) { - withContext(Dispatchers.Default) { + convertLock.withLock { + runInterruptible(Dispatchers.Default) { val image = BitmapFactory.decodeFile(file.absolutePath) try { file.outputStream().use { out -> @@ -101,5 +114,76 @@ class PageLoader( } } - private companion object Lock -} + private fun onIdle() { + synchronized(prefetchQueue) { + val page = prefetchQueue.pollFirst() ?: return + tasks[page.id] = loadPageAsyncImpl(page) + } + } + + private fun loadPageAsyncImpl(page: MangaPage): ProgressDeferred { + val progress = MutableStateFlow(PROGRESS_UNDEFINED) + val deferred = loaderScope.async { + counter.incrementAndGet() + try { + loadPageImpl(page, progress) + } finally { + if (counter.decrementAndGet() == 0) { + onIdle() + } + } + } + return ProgressDeferred(deferred, progress) + } + + @Synchronized + private fun getRepository(source: MangaSource): MangaRepository { + val result = repository + return if (result != null && result.source == source) { + result + } else { + mangaRepositoryOf(source).also { repository = it } + } + } + + private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow): File { + val pageUrl = getRepository(page.source).getPageUrl(page) + check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } + val uri = Uri.parse(pageUrl) + return if (uri.scheme == "cbz") { + runInterruptible(Dispatchers.IO) { + val zip = ZipFile(uri.schemeSpecificPart) + val entry = zip.getEntry(uri.fragment) + zip.getInputStream(entry).use { + cache.put(pageUrl, it) + } + } + } else { + val request = Request.Builder() + .url(pageUrl) + .get() + .header(CommonHeaders.REFERER, page.referer) + .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") + .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) + .build() + okHttp.newCall(request).await().use { response -> + check(response.isSuccessful) { + "Invalid response: ${response.code} ${response.message}" + } + val body = checkNotNull(response.body) { + "Null response" + } + runInterruptible(Dispatchers.IO) { + body.byteStream().use { + cache.put(pageUrl, it, body.contentLength(), progress) + } + } + } + } + } + + private fun getCompletedTask(file: File): ProgressDeferred { + val deferred = CompletableDeferred(file) + return ProgressDeferred(deferred, emptyProgressFlow) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 2e360594c..974655089 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.utils.DownloadManagerHelper @@ -45,6 +46,8 @@ class ReaderViewModel( private val mangaData = MutableStateFlow(intent.manga) private val chapters = LongSparseArray() + val pageLoader = PageLoader() + val readerMode = MutableLiveData() val onPageSaved = SingleLiveEvent() val uiState = combine( @@ -126,6 +129,11 @@ class ReaderViewModel( subscribeToSettings() } + override fun onCleared() { + pageLoader.close() + super.onCleared() + } + fun switchMode(newMode: ReaderMode) { launchJob { val manga = checkNotNull(mangaData.value) @@ -206,6 +214,9 @@ class ReaderViewModel( if (position >= pages.size - BOUNDS_PAGE_OFFSET) { loadPrevNextChapter(pages.last().chapterId, 1) } + if (pageLoader.isPrefetchApplicable()) { + pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT)) + } } private fun getReaderMode(isWebtoon: Boolean?) = when { @@ -262,10 +273,21 @@ class ReaderViewModel( .launchIn(viewModelScope + Dispatchers.IO) } + private fun List.trySublist(fromIndex: Int, toIndex: Int): List { + val fromIndexBounded = fromIndex.coerceAtMost(lastIndex) + val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex) + return if (fromIndexBounded == toIndexBounded) { + emptyList() + } else { + subList(fromIndexBounded, toIndexBounded) + } + } + private companion object : KoinComponent { const val BOUNDS_PAGE_OFFSET = 2 const val PAGES_TRIM_THRESHOLD = 120 + const val PREFETCH_LIMIT = 10 fun saveState(manga: Manga, state: ReaderState) { processLifecycleScope.launch(Dispatchers.Default + IgnoreErrors) { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt index a9cdaf2ad..d538d7f45 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.reader.ui.pager import android.content.Context +import androidx.annotation.CallSuper import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver @@ -33,5 +34,8 @@ abstract class BasePageHolder( protected abstract fun onBind(data: ReaderPage) - open fun onRecycled() = Unit + @CallSuper + open fun onRecycled() { + delegate.onRecycle() + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt index 4035d9854..7b7c4d498 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt @@ -3,12 +3,9 @@ package org.koitharu.kotatsu.reader.ui.pager import android.os.Bundle import android.view.View import androidx.core.graphics.Insets -import androidx.lifecycle.lifecycleScope import androidx.viewbinding.ViewBinding -import org.koin.android.ext.android.get import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.base.ui.BaseFragment -import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderViewModel @@ -17,9 +14,6 @@ private const val KEY_STATE = "state" abstract class BaseReader : BaseFragment() { protected val viewModel by sharedViewModel() - protected val loader by lazy(LazyThreadSafetyMode.NONE) { - PageLoader(lifecycleScope, get(), get()) - } private var stateToSave: ReaderState? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index e9f9598c8..041bc38da 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -4,14 +4,16 @@ import android.net.Uri import androidx.core.net.toUri import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.utils.ext.launchAfter -import org.koitharu.kotatsu.utils.ext.launchInstead import java.io.File import java.io.IOException @@ -20,21 +22,26 @@ class PageHolderDelegate( private val settings: AppSettings, private val callback: Callback, private val exceptionResolver: ExceptionResolver -) : SubsamplingScaleImageView.DefaultOnImageEventListener(), CoroutineScope by loader { +) : SubsamplingScaleImageView.DefaultOnImageEventListener() { + private val scope = loader.loaderScope + Dispatchers.Main.immediate private var state = State.EMPTY private var job: Job? = null private var file: File? = null private var error: Throwable? = null fun onBind(page: MangaPage) { - job = launchInstead(job) { + val prevJob = job + job = scope.launch { + prevJob?.cancelAndJoin() doLoad(page, force = false) } } fun retry(page: MangaPage) { - job = launchInstead(job) { + val prevJob = job + job = scope.launch { + prevJob?.cancelAndJoin() (error as? ResolvableException)?.let { exceptionResolver.resolve(it) } @@ -65,30 +72,39 @@ class PageHolderDelegate( val file = this.file error = e if (state == State.LOADED && e is IOException && file != null && file.exists()) { - job = launchAfter(job) { - state = State.CONVERTING - try { - loader.convertInPlace(file) - state = State.CONVERTED - callback.onImageReady(file.toUri()) - } catch (e2: Throwable) { - e.addSuppressed(e2) - state = State.ERROR - callback.onError(e) - } - } + tryConvert(file, e) } else { state = State.ERROR callback.onError(e) } } - private suspend fun doLoad(data: MangaPage, force: Boolean) { + private fun tryConvert(file: File, e: Exception) { + val prevJob = job + job = scope.launch { + prevJob?.join() + state = State.CONVERTING + try { + loader.convertInPlace(file) + state = State.CONVERTED + callback.onImageReady(file.toUri()) + } catch (e2: Throwable) { + e.addSuppressed(e2) + state = State.ERROR + callback.onError(e) + } + } + } + + private suspend fun CoroutineScope.doLoad(data: MangaPage, force: Boolean) { state = State.LOADING error = null callback.onLoadingStarted() try { - val file = loader.loadPage(data, force) + val task = loader.loadPageAsync(data, force) + val progressObserver = observeProgress(this, task.progressAsFlow()) + val file = task.await() + progressObserver.cancel() this@PageHolderDelegate.file = file state = State.LOADED callback.onImageReady(file.toUri()) @@ -101,6 +117,11 @@ class PageHolderDelegate( } } + private fun observeProgress(scope: CoroutineScope, progress: Flow) = progress + .debounce(500) + .onEach { callback.onProgressChanged((100 * it).toInt()) } + .launchIn(scope) + private enum class State { EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR } @@ -116,5 +137,7 @@ class PageHolderDelegate( fun onImageShowing(zoom: ZoomMode) fun onImageShown() + + fun onProgressChanged(progress: Int) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt index 9da14c353..cff351d9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt @@ -13,7 +13,10 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.doOnPageChanged +import org.koitharu.kotatsu.utils.ext.recyclerView +import org.koitharu.kotatsu.utils.ext.resetTransformations +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import kotlin.math.absoluteValue class ReversedReaderFragment : BaseReader() { @@ -27,7 +30,7 @@ class ReversedReaderFragment : BaseReader() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - pagerAdapter = ReversedPagesAdapter(loader, get(), exceptionResolver) + pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, get(), exceptionResolver) with(binding.pager) { adapter = pagerAdapter offscreenPageLimit = 2 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 bd630b8d0..ff1828c00 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 @@ -37,7 +37,7 @@ open class PageHolder( } override fun onRecycled() { - delegate.onRecycle() + super.onRecycled() binding.ssiv.recycle() } @@ -47,6 +47,15 @@ open class PageHolder( binding.ssiv.recycle() } + override fun onProgressChanged(progress: Int) { + if (progress in 0..100) { + binding.progressBar.isIndeterminate = false + binding.progressBar.setProgressCompat(progress, true) + } else { + binding.progressBar.isIndeterminate = true + } + } + override fun onImageReady(uri: Uri) { binding.ssiv.setImage(ImageSource.uri(uri)) } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt index d2d2a6b50..a8cd77e17 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt @@ -29,7 +29,7 @@ class PagerReaderFragment : BaseReader() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - pagesAdapter = PagesAdapter(loader, get(), exceptionResolver) + pagesAdapter = PagesAdapter(viewModel.pageLoader, get(), exceptionResolver) with(binding.pager) { adapter = pagesAdapter offscreenPageLimit = 2 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 fcb81c602..33c29bd4b 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 @@ -37,7 +37,7 @@ class WebtoonHolder( } override fun onRecycled() { - delegate.onRecycle() + super.onRecycled() binding.ssiv.recycle() } @@ -47,6 +47,15 @@ class WebtoonHolder( binding.ssiv.recycle() } + override fun onProgressChanged(progress: Int) { + if (progress in 0..100) { + binding.progressBar.isIndeterminate = false + binding.progressBar.setProgressCompat(progress, true) + } else { + binding.progressBar.isIndeterminate = true + } + } + override fun onImageReady(uri: Uri) { binding.ssiv.setImage(ImageSource.uri(uri)) } 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 1cb0b1079..ddaa63b5e 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 @@ -12,7 +12,10 @@ import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged +import org.koitharu.kotatsu.utils.ext.findCenterViewPosition +import org.koitharu.kotatsu.utils.ext.firstItem +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope class WebtoonReaderFragment : BaseReader() { @@ -26,7 +29,7 @@ class WebtoonReaderFragment : BaseReader() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - webtoonAdapter = WebtoonAdapter(loader, get(), exceptionResolver) + webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, get(), exceptionResolver) with(binding.recyclerView) { setHasFixedSize(true) adapter = webtoonAdapter diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt deleted file mode 100644 index 5cb69e049..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.koitharu.kotatsu.utils - -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn - -class DeferredStateFlow( - private val stateFlow: StateFlow, - private val deferred: Deferred, -) : StateFlow by stateFlow, Deferred by deferred { - - suspend fun collectAndAwait(): R { - return coroutineScope { - val collectJob = launchIn(this) - val result = await() - collectJob.cancelAndJoin() - result - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt deleted file mode 100644 index 05af51f10..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.utils - -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn - -class JobStateFlow( - private val stateFlow: StateFlow, - private val job: Job, -) : StateFlow by stateFlow, Job by job { - - suspend fun collectAndJoin(): Unit { - coroutineScope { - val collectJob = launchIn(this) - join() - collectJob.cancelAndJoin() - } - } -} \ 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 215e2d185..c9f5a5753 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 @@ -4,11 +4,16 @@ import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkRequest +import android.os.Bundle +import android.os.Parcelable import androidx.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume +val Context.connectivityManager: ConnectivityManager + get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + suspend fun ConnectivityManager.waitForNetwork(): Network { val request = NetworkRequest.Builder().build() return suspendCancellableCoroutine { cont -> @@ -26,4 +31,10 @@ suspend fun ConnectivityManager.waitForNetwork(): Network { inline fun buildAlertDialog(context: Context, block: MaterialAlertDialogBuilder.() -> Unit): AlertDialog { return MaterialAlertDialogBuilder(context).apply(block).create() +} + +fun Bundle.requireParcelable(key: String): T { + return checkNotNull(getParcelable(key)) { + "Value for key $key not found" + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt index f98427092..40f1268b9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt @@ -4,47 +4,9 @@ import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.* -import okhttp3.Call -import okhttp3.Callback -import okhttp3.Response import org.koitharu.kotatsu.BuildConfig -import java.io.IOException import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -inline fun CoroutineScope.launchAfter( - job: Job?, - context: CoroutineContext = EmptyCoroutineContext, - start: CoroutineStart = CoroutineStart.DEFAULT, - crossinline block: suspend CoroutineScope.() -> Unit -): Job = launch(context, start) { - try { - job?.join() - } catch (e: Throwable) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } - } - block() -} - -inline fun CoroutineScope.launchInstead( - job: Job?, - context: CoroutineContext = EmptyCoroutineContext, - start: CoroutineStart = CoroutineStart.DEFAULT, - crossinline block: suspend CoroutineScope.() -> Unit -): Job = launch(context, start) { - try { - job?.cancelAndJoin() - } catch (e: Throwable) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } - } - block() -} val IgnoreErrors get() = CoroutineExceptionHandler { _, e -> 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 95546d3c2..243356b1d 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 @@ -166,6 +166,7 @@ inline fun RecyclerView.ViewHolder.getItem(): T? { return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T) } +@Deprecated("Useless") fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) { if (isIndeterminate != indeterminate) { if (indeterminate && visibility == View.VISIBLE) { diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressDeferred.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressDeferred.kt new file mode 100644 index 000000000..7fd1a9357 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressDeferred.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.utils.progress + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +class ProgressDeferred( + private val deferred: Deferred, + private val progress: StateFlow

, +) : Deferred by deferred { + + val progressValue: P + get() = progress.value + + fun progressAsFlow(): Flow

= progress +} \ 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 new file mode 100644 index 000000000..d401fc83a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.utils.progress + +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +class ProgressJob

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

, +) : Job by job { + + val progressValue: P + get() = progress.value + + fun progressAsFlow(): Flow

= progress +} \ No newline at end of file diff --git a/app/src/main/res/layout/item_page.xml b/app/src/main/res/layout/item_page.xml index 2ce0dffbe..7f1db2f87 100644 --- a/app/src/main/res/layout/item_page.xml +++ b/app/src/main/res/layout/item_page.xml @@ -14,20 +14,21 @@ + android:layout_gravity="center" + android:indeterminate="true" + android:max="100" /> + android:visibility="gone"> - + android:layout_gravity="center" + android:indeterminate="true" + android:max="100" /> @string/screenshots_block_nsfw @string/screenshots_block_all + + @string/always + @string/only_using_wifi + @string/never + \ No newline at end of file diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 2670a1bc5..b7f8d578d 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -24,4 +24,9 @@ block_nsfw block_all + + 1 + 2 + 0 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ffc1c4f6a..1e3a5c93f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -261,4 +261,8 @@ Reset filter Find genre Select languages which you want to read manga. You can change it later in settings. + Never + Only using WiFi + Always + Preload pages \ No newline at end of file diff --git a/app/src/main/res/xml/pref_reader.xml b/app/src/main/res/xml/pref_reader.xml index 1d3db4527..8fa00ef0e 100644 --- a/app/src/main/res/xml/pref_reader.xml +++ b/app/src/main/res/xml/pref_reader.xml @@ -47,4 +47,13 @@ app:iconSpaceReserved="false" app:useSimpleSummaryProvider="true" /> + + \ No newline at end of file