Merge branch 'feature/page-preload' into devel

pull/123/head
Koitharu 4 years ago
commit 15c570979b
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
@ -147,6 +148,14 @@ class AppSettings(context: Context) {
val isSuggestionsExcludeNsfw: Boolean val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) 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 = fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
when (format) { when (format) {
"" -> DateFormat.getDateInstance(DateFormat.SHORT) "" -> DateFormat.getDateInstance(DateFormat.SHORT)
@ -237,6 +246,7 @@ class AppSettings(context: Context) {
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers" const val KEY_PAGES_NUMBERS = "pages_numbers"
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
const val KEY_PAGES_PRELOAD = "pages_preload"
const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" 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_FEEDBACK_GITHUB = "about_feedback_github"
const val KEY_SUPPORT_DEVELOPER = "about_support_developer" 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 val isDynamicColorAvailable: Boolean
get() = DynamicColors.isDynamicColorAvailable() || get() = DynamicColors.isDynamicColorAvailable() ||
(isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) (isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)

@ -10,13 +10,13 @@ import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.progress.ProgressJob
fun downloadItemAD( fun downloadItemAD(
scope: CoroutineScope, scope: CoroutineScope,
coil: ImageLoader, coil: ImageLoader,
) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>( ) = adapterDelegateViewBinding<ProgressJob<DownloadManager.State>, ProgressJob<DownloadManager.State>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) { ) {
@ -24,7 +24,7 @@ fun downloadItemAD(
bind { bind {
job?.cancel() job?.cancel()
job = item.onFirst { state -> job = item.progressAsFlow().onFirst { state ->
binding.imageViewCover.newImageRequest(state.manga.coverUrl) binding.imageViewCover.newImageRequest(state.manga.coverUrl)
.referer(state.manga.publicUrl) .referer(state.manga.publicUrl)
.placeholder(state.cover) .placeholder(state.cover)

@ -5,12 +5,12 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow import org.koitharu.kotatsu.utils.progress.ProgressJob
class DownloadsAdapter( class DownloadsAdapter(
scope: CoroutineScope, scope: CoroutineScope,
coil: ImageLoader, coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadManager.State>>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(downloadItemAD(scope, coil)) delegatesManager.addDelegate(downloadItemAD(scope, coil))
@ -18,23 +18,23 @@ class DownloadsAdapter(
} }
override fun getItemId(position: Int): Long { override fun getItemId(position: Int): Long {
return items[position].value.startId.toLong() return items[position].progressValue.startId.toLong()
} }
private class DiffCallback : DiffUtil.ItemCallback<JobStateFlow<DownloadManager.State>>() { private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadManager.State>>() {
override fun areItemsTheSame( override fun areItemsTheSame(
oldItem: JobStateFlow<DownloadManager.State>, oldItem: ProgressJob<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>, newItem: ProgressJob<DownloadManager.State>,
): Boolean { ): Boolean {
return oldItem.value.startId == newItem.value.startId return oldItem.progressValue.startId == newItem.progressValue.startId
} }
override fun areContentsTheSame( override fun areContentsTheSame(
oldItem: JobStateFlow<DownloadManager.State>, oldItem: ProgressJob<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>, newItem: ProgressJob<DownloadManager.State>,
): Boolean { ): Boolean {
return oldItem.value == newItem.value return oldItem.progressValue == newItem.progressValue
} }
} }
} }

@ -4,7 +4,6 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Binder import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager 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.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager 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.ext.toArraySet
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.set import kotlin.collections.set
@ -42,7 +42,7 @@ class DownloadService : BaseService() {
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var downloadManager: DownloadManager private lateinit var downloadManager: DownloadManager
private val jobs = LinkedHashMap<Int, JobStateFlow<DownloadManager.State>>() private val jobs = LinkedHashMap<Int, ProgressJob<DownloadManager.State>>()
private val jobCount = MutableStateFlow(0) private val jobCount = MutableStateFlow(0)
private val mutex = Mutex() private val mutex = Mutex()
private val controlReceiver = ControlReceiver() private val controlReceiver = ControlReceiver()
@ -93,7 +93,7 @@ class DownloadService : BaseService() {
startId: Int, startId: Int,
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: Set<Long>?,
): JobStateFlow<DownloadManager.State> { ): ProgressJob<DownloadManager.State> {
val initialState = DownloadManager.State.Queued(startId, manga, null) val initialState = DownloadManager.State.Queued(startId, manga, null)
val stateFlow = MutableStateFlow<DownloadManager.State>(initialState) val stateFlow = MutableStateFlow<DownloadManager.State>(initialState)
val job = lifecycleScope.launch { val job = lifecycleScope.launch {
@ -131,7 +131,7 @@ class DownloadService : BaseService() {
} }
} }
} }
return JobStateFlow(stateFlow, job) return ProgressJob(job, stateFlow)
} }
inner class ControlReceiver : BroadcastReceiver() { inner class ControlReceiver : BroadcastReceiver() {
@ -149,7 +149,7 @@ class DownloadService : BaseService() {
class DownloadBinder(private val service: DownloadService) : Binder() { class DownloadBinder(private val service: DownloadService) : Binder() {
val downloads: Flow<Collection<JobStateFlow<DownloadManager.State>>> val downloads: Flow<Collection<ProgressJob<DownloadManager.State>>>
get() = service.jobCount.mapLatest { service.jobs.values } get() = service.jobCount.mapLatest { service.jobs.values }
} }
@ -183,9 +183,8 @@ class DownloadService : BaseService() {
.putExtra(ACTION_DOWNLOAD_CANCEL, startId) .putExtra(ACTION_DOWNLOAD_CANCEL, startId)
private fun confirmDataTransfer(context: Context, callback: () -> Unit) { private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val settings = GlobalContext.get().get<AppSettings>() val settings = GlobalContext.get().get<AppSettings>()
if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) { if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
CheckBoxAlertDialog.Builder(context) CheckBoxAlertDialog.Builder(context)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage(R.string.network_consumption_warning) .setMessage(R.string.network_consumption_warning)

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data
import android.content.Context import android.content.Context
import com.tomclaw.cache.DiskLruCache import com.tomclaw.cache.DiskLruCache
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.subdir
@ -30,4 +31,33 @@ class PagesCache(context: Context) {
file.delete() file.delete()
return res return res
} }
fun put(
url: String,
inputStream: InputStream,
contentLength: Long,
progress: MutableStateFlow<Float>,
): 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<Float>) {
if (contentLength > 0) {
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
}
}
} }

@ -1,94 +1,107 @@
package org.koitharu.kotatsu.reader.domain package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okio.Closeable
import org.koin.core.component.KoinComponent 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.MangaPage
import org.koitharu.kotatsu.core.model.MangaSource
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.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache 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.await
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf
import org.koitharu.kotatsu.utils.progress.ProgressDeferred
import java.io.File import java.io.File
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile import java.util.zip.ZipFile
class PageLoader( private const val PROGRESS_UNDEFINED = -1f
scope: CoroutineScope, private const val PREFETCH_LIMIT_DEFAULT = 10
private val okHttp: OkHttpClient,
private val cache: PagesCache
) : CoroutineScope by scope, KoinComponent {
private var repository: MangaRepository? = null class PageLoader : KoinComponent, Closeable {
private val tasks = LongSparseArray<Deferred<File>>()
val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val okHttp = get<OkHttpClient>()
private val cache = get<PagesCache>()
private val settings = get<AppSettings>()
private val connectivityManager = get<Context>().connectivityManager
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
private val convertLock = Mutex() private val convertLock = Mutex()
private var repository: MangaRepository? = null
private var prefetchQueue = LinkedList<MangaPage>()
private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
private val emptyProgressFlow: StateFlow<Float> = 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<ReaderPage>) {
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<File, Float> {
if (!force) { if (!force) {
cache[page.url]?.let { cache[page.url]?.let {
return it return getCompletedTask(it)
} }
} }
var task = tasks[page.id] var task = tasks[page.id]
if (force) { if (force) {
task?.cancel() task?.cancel()
} else if (task?.isCancelled == false) { } else if (task?.isCancelled == false) {
return task.await() return task
} }
task = loadAsync(page) task = loadPageAsyncImpl(page)
tasks[page.id] = task tasks[page.id] = task
return task.await() return task
} }
private fun loadAsync(page: MangaPage): Deferred<File> { suspend fun loadPage(page: MangaPage, force: Boolean): File {
var repo = repository return loadPageAsync(page, force).await()
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 convertInPlace(file: File) { suspend fun convertInPlace(file: File) {
convertLock.withLock(Lock) { convertLock.withLock {
withContext(Dispatchers.Default) { runInterruptible(Dispatchers.Default) {
val image = BitmapFactory.decodeFile(file.absolutePath) val image = BitmapFactory.decodeFile(file.absolutePath)
try { try {
file.outputStream().use { out -> 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<File, Float> {
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<Float>): 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<File, Float> {
val deferred = CompletableDeferred(file)
return ProgressDeferred(deferred, emptyProgressFlow)
}
}

@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
import org.koitharu.kotatsu.history.domain.HistoryRepository 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.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.DownloadManagerHelper import org.koitharu.kotatsu.utils.DownloadManagerHelper
@ -45,6 +46,8 @@ class ReaderViewModel(
private val mangaData = MutableStateFlow(intent.manga) private val mangaData = MutableStateFlow(intent.manga)
private val chapters = LongSparseArray<MangaChapter>() private val chapters = LongSparseArray<MangaChapter>()
val pageLoader = PageLoader()
val readerMode = MutableLiveData<ReaderMode>() val readerMode = MutableLiveData<ReaderMode>()
val onPageSaved = SingleLiveEvent<Uri?>() val onPageSaved = SingleLiveEvent<Uri?>()
val uiState = combine( val uiState = combine(
@ -126,6 +129,11 @@ class ReaderViewModel(
subscribeToSettings() subscribeToSettings()
} }
override fun onCleared() {
pageLoader.close()
super.onCleared()
}
fun switchMode(newMode: ReaderMode) { fun switchMode(newMode: ReaderMode) {
launchJob { launchJob {
val manga = checkNotNull(mangaData.value) val manga = checkNotNull(mangaData.value)
@ -206,6 +214,9 @@ class ReaderViewModel(
if (position >= pages.size - BOUNDS_PAGE_OFFSET) { if (position >= pages.size - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, 1) loadPrevNextChapter(pages.last().chapterId, 1)
} }
if (pageLoader.isPrefetchApplicable()) {
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
}
} }
private fun getReaderMode(isWebtoon: Boolean?) = when { private fun getReaderMode(isWebtoon: Boolean?) = when {
@ -262,10 +273,21 @@ class ReaderViewModel(
.launchIn(viewModelScope + Dispatchers.IO) .launchIn(viewModelScope + Dispatchers.IO)
} }
private fun <T> List<T>.trySublist(fromIndex: Int, toIndex: Int): List<T> {
val fromIndexBounded = fromIndex.coerceAtMost(lastIndex)
val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex)
return if (fromIndexBounded == toIndexBounded) {
emptyList()
} else {
subList(fromIndexBounded, toIndexBounded)
}
}
private companion object : KoinComponent { private companion object : KoinComponent {
const val BOUNDS_PAGE_OFFSET = 2 const val BOUNDS_PAGE_OFFSET = 2
const val PAGES_TRIM_THRESHOLD = 120 const val PAGES_TRIM_THRESHOLD = 120
const val PREFETCH_LIMIT = 10
fun saveState(manga: Manga, state: ReaderState) { fun saveState(manga: Manga, state: ReaderState) {
processLifecycleScope.launch(Dispatchers.Default + IgnoreErrors) { processLifecycleScope.launch(Dispatchers.Default + IgnoreErrors) {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.reader.ui.pager package org.koitharu.kotatsu.reader.ui.pager
import android.content.Context import android.content.Context
import androidx.annotation.CallSuper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@ -33,5 +34,8 @@ abstract class BasePageHolder<B : ViewBinding>(
protected abstract fun onBind(data: ReaderPage) protected abstract fun onBind(data: ReaderPage)
open fun onRecycled() = Unit @CallSuper
open fun onRecycled() {
delegate.onRecycle()
}
} }

@ -3,12 +3,9 @@ package org.koitharu.kotatsu.reader.ui.pager
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.base.ui.BaseFragment 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.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
@ -17,9 +14,6 @@ private const val KEY_STATE = "state"
abstract class BaseReader<B : ViewBinding> : BaseFragment<B>() { abstract class BaseReader<B : ViewBinding> : BaseFragment<B>() {
protected val viewModel by sharedViewModel<ReaderViewModel>() protected val viewModel by sharedViewModel<ReaderViewModel>()
protected val loader by lazy(LazyThreadSafetyMode.NONE) {
PageLoader(lifecycleScope, get(), get())
}
private var stateToSave: ReaderState? = null private var stateToSave: ReaderState? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

@ -4,14 +4,16 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.* 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.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.PageLoader 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.File
import java.io.IOException import java.io.IOException
@ -20,21 +22,26 @@ class PageHolderDelegate(
private val settings: AppSettings, private val settings: AppSettings,
private val callback: Callback, private val callback: Callback,
private val exceptionResolver: ExceptionResolver 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 state = State.EMPTY
private var job: Job? = null private var job: Job? = null
private var file: File? = null private var file: File? = null
private var error: Throwable? = null private var error: Throwable? = null
fun onBind(page: MangaPage) { fun onBind(page: MangaPage) {
job = launchInstead(job) { val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
doLoad(page, force = false) doLoad(page, force = false)
} }
} }
fun retry(page: MangaPage) { fun retry(page: MangaPage) {
job = launchInstead(job) { val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
(error as? ResolvableException)?.let { (error as? ResolvableException)?.let {
exceptionResolver.resolve(it) exceptionResolver.resolve(it)
} }
@ -65,30 +72,39 @@ class PageHolderDelegate(
val file = this.file val file = this.file
error = e error = e
if (state == State.LOADED && e is IOException && file != null && file.exists()) { if (state == State.LOADED && e is IOException && file != null && file.exists()) {
job = launchAfter(job) { tryConvert(file, e)
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)
}
}
} else { } else {
state = State.ERROR state = State.ERROR
callback.onError(e) 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 state = State.LOADING
error = null error = null
callback.onLoadingStarted() callback.onLoadingStarted()
try { 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 this@PageHolderDelegate.file = file
state = State.LOADED state = State.LOADED
callback.onImageReady(file.toUri()) callback.onImageReady(file.toUri())
@ -101,6 +117,11 @@ class PageHolderDelegate(
} }
} }
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
.debounce(500)
.onEach { callback.onProgressChanged((100 * it).toInt()) }
.launchIn(scope)
private enum class State { private enum class State {
EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
} }
@ -116,5 +137,7 @@ class PageHolderDelegate(
fun onImageShowing(zoom: ZoomMode) fun onImageShowing(zoom: ZoomMode)
fun onImageShown() fun onImageShown()
fun onProgressChanged(progress: Int)
} }
} }

@ -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.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment 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 import kotlin.math.absoluteValue
class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() { class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@ -27,7 +30,7 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter(loader, get(), exceptionResolver) pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, get(), exceptionResolver)
with(binding.pager) { with(binding.pager) {
adapter = pagerAdapter adapter = pagerAdapter
offscreenPageLimit = 2 offscreenPageLimit = 2

@ -37,7 +37,7 @@ open class PageHolder(
} }
override fun onRecycled() { override fun onRecycled() {
delegate.onRecycle() super.onRecycled()
binding.ssiv.recycle() binding.ssiv.recycle()
} }
@ -47,6 +47,15 @@ open class PageHolder(
binding.ssiv.recycle() 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) { override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.uri(uri)) binding.ssiv.setImage(ImageSource.uri(uri))
} }

@ -29,7 +29,7 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter(loader, get(), exceptionResolver) pagesAdapter = PagesAdapter(viewModel.pageLoader, get(), exceptionResolver)
with(binding.pager) { with(binding.pager) {
adapter = pagesAdapter adapter = pagesAdapter
offscreenPageLimit = 2 offscreenPageLimit = 2

@ -37,7 +37,7 @@ class WebtoonHolder(
} }
override fun onRecycled() { override fun onRecycled() {
delegate.onRecycle() super.onRecycled()
binding.ssiv.recycle() binding.ssiv.recycle()
} }
@ -47,6 +47,15 @@ class WebtoonHolder(
binding.ssiv.recycle() 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) { override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.uri(uri)) binding.ssiv.setImage(ImageSource.uri(uri))
} }

@ -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.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage 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<FragmentReaderWebtoonBinding>() { class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
@ -26,7 +29,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
webtoonAdapter = WebtoonAdapter(loader, get(), exceptionResolver) webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, get(), exceptionResolver)
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
adapter = webtoonAdapter adapter = webtoonAdapter

@ -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<R, S>(
private val stateFlow: StateFlow<S>,
private val deferred: Deferred<R>,
) : StateFlow<S> by stateFlow, Deferred<R> by deferred {
suspend fun collectAndAwait(): R {
return coroutineScope {
val collectJob = launchIn(this)
val result = await()
collectJob.cancelAndJoin()
result
}
}
}

@ -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<S>(
private val stateFlow: StateFlow<S>,
private val job: Job,
) : StateFlow<S> by stateFlow, Job by job {
suspend fun collectAndJoin(): Unit {
coroutineScope {
val collectJob = launchIn(this)
join()
collectJob.cancelAndJoin()
}
}
}

@ -4,11 +4,16 @@ import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.Bundle
import android.os.Parcelable
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume import kotlin.coroutines.resume
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
suspend fun ConnectivityManager.waitForNetwork(): Network { suspend fun ConnectivityManager.waitForNetwork(): Network {
val request = NetworkRequest.Builder().build() val request = NetworkRequest.Builder().build()
return suspendCancellableCoroutine<Network> { cont -> return suspendCancellableCoroutine<Network> { cont ->
@ -26,4 +31,10 @@ suspend fun ConnectivityManager.waitForNetwork(): Network {
inline fun buildAlertDialog(context: Context, block: MaterialAlertDialogBuilder.() -> Unit): AlertDialog { inline fun buildAlertDialog(context: Context, block: MaterialAlertDialogBuilder.() -> Unit): AlertDialog {
return MaterialAlertDialogBuilder(context).apply(block).create() return MaterialAlertDialogBuilder(context).apply(block).create()
}
fun <T : Parcelable> Bundle.requireParcelable(key: String): T {
return checkNotNull(getParcelable(key)) {
"Value for key $key not found"
}
} }

@ -4,47 +4,9 @@ import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import java.io.IOException
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext 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 val IgnoreErrors
get() = CoroutineExceptionHandler { _, e -> get() = CoroutineExceptionHandler { _, e ->

@ -166,6 +166,7 @@ inline fun <reified T> RecyclerView.ViewHolder.getItem(): T? {
return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T) return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
} }
@Deprecated("Useless")
fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) { fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) {
if (isIndeterminate != indeterminate) { if (isIndeterminate != indeterminate) {
if (indeterminate && visibility == View.VISIBLE) { if (indeterminate && visibility == View.VISIBLE) {

@ -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<T, P>(
private val deferred: Deferred<T>,
private val progress: StateFlow<P>,
) : Deferred<T> by deferred {
val progressValue: P
get() = progress.value
fun progressAsFlow(): Flow<P> = progress
}

@ -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<P>(
private val job: Job,
private val progress: StateFlow<P>,
) : Job by job {
val progressValue: P
get() = progress.value
fun progressAsFlow(): Flow<P> = progress
}

@ -14,20 +14,21 @@
<com.google.android.material.progressindicator.CircularProgressIndicator <com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar" android:id="@+id/progressBar"
android:indeterminate="true"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" /> android:layout_gravity="center"
android:indeterminate="true"
android:max="100" />
<TextView <TextView
android:id="@+id/textView_number" android:id="@+id/textView_number"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_margin="8dp" android:layout_margin="8dp"
android:singleLine="true" android:singleLine="true"
android:textColor="?android:textColorTertiary"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
android:layout_height="wrap_content" android:textColor="?android:textColorTertiary"
android:layout_gravity="end|bottom"
tools:text="5" /> tools:text="5" />
<LinearLayout <LinearLayout
@ -39,8 +40,7 @@
android:layout_marginEnd="60dp" android:layout_marginEnd="60dp"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="vertical" android:orientation="vertical"
android:visibility="gone" android:visibility="gone">
tools:visibility="visible">
<TextView <TextView
android:id="@+id/textView_error" android:id="@+id/textView_error"
@ -52,7 +52,7 @@
app:drawableTopCompat="@drawable/ic_error_large" app:drawableTopCompat="@drawable/ic_error_large"
tools:text="@tools:sample/lorem[6]" /> tools:text="@tools:sample/lorem[6]" />
<com.google.android.material.button.MaterialButton <Button
android:id="@+id/button_retry" android:id="@+id/button_retry"
style="@style/Widget.Material3.Button.TonalButton" style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"

@ -16,10 +16,11 @@
<com.google.android.material.progressindicator.CircularProgressIndicator <com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar" android:id="@+id/progressBar"
android:indeterminate="true"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" /> android:layout_gravity="center"
android:indeterminate="true"
android:max="100" />
<LinearLayout <LinearLayout
android:id="@+id/layout_error" android:id="@+id/layout_error"

@ -29,4 +29,9 @@
<item>@string/screenshots_block_nsfw</item> <item>@string/screenshots_block_nsfw</item>
<item>@string/screenshots_block_all</item> <item>@string/screenshots_block_all</item>
</string-array> </string-array>
<string-array name="network_policy">
<item>@string/always</item>
<item>@string/only_using_wifi</item>
<item>@string/never</item>
</string-array>
</resources> </resources>

@ -24,4 +24,9 @@
<item>block_nsfw</item> <item>block_nsfw</item>
<item>block_all</item> <item>block_all</item>
</string-array> </string-array>
<string-array name="values_network_policy">
<item>1</item>
<item>2</item>
<item>0</item>
</string-array>
</resources> </resources>

@ -261,4 +261,8 @@
<string name="reset_filter">Reset filter</string> <string name="reset_filter">Reset filter</string>
<string name="find_genre">Find genre</string> <string name="find_genre">Find genre</string>
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string> <string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
<string name="never">Never</string>
<string name="only_using_wifi">Only using WiFi</string>
<string name="always">Always</string>
<string name="preload_pages">Preload pages</string>
</resources> </resources>

@ -47,4 +47,13 @@
app:iconSpaceReserved="false" app:iconSpaceReserved="false"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<ListPreference
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="pages_preload"
android:title="@string/preload_pages"
app:defaultValue="2"
app:iconSpaceReserved="false"
app:useSimpleSummaryProvider="true" />
</PreferenceScreen> </PreferenceScreen>
Loading…
Cancel
Save