Merge branch 'feature/page-preload' into devel
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>(
|
||||
) = adapterDelegateViewBinding<ProgressJob<DownloadManager.State>, ProgressJob<DownloadManager.State>, 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)
|
||||
|
||||
@@ -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<JobStateFlow<DownloadManager.State>>(DiffCallback()) {
|
||||
) : AsyncListDifferDelegationAdapter<ProgressJob<DownloadManager.State>>(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<JobStateFlow<DownloadManager.State>>() {
|
||||
private class DiffCallback : DiffUtil.ItemCallback<ProgressJob<DownloadManager.State>>() {
|
||||
|
||||
override fun areItemsTheSame(
|
||||
oldItem: JobStateFlow<DownloadManager.State>,
|
||||
newItem: JobStateFlow<DownloadManager.State>,
|
||||
oldItem: ProgressJob<DownloadManager.State>,
|
||||
newItem: ProgressJob<DownloadManager.State>,
|
||||
): Boolean {
|
||||
return oldItem.value.startId == newItem.value.startId
|
||||
return oldItem.progressValue.startId == newItem.progressValue.startId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: JobStateFlow<DownloadManager.State>,
|
||||
newItem: JobStateFlow<DownloadManager.State>,
|
||||
oldItem: ProgressJob<DownloadManager.State>,
|
||||
newItem: ProgressJob<DownloadManager.State>,
|
||||
): 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.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<Int, JobStateFlow<DownloadManager.State>>()
|
||||
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadManager.State>>()
|
||||
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<Long>?,
|
||||
): JobStateFlow<DownloadManager.State> {
|
||||
): ProgressJob<DownloadManager.State> {
|
||||
val initialState = DownloadManager.State.Queued(startId, manga, null)
|
||||
val stateFlow = MutableStateFlow<DownloadManager.State>(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<Collection<JobStateFlow<DownloadManager.State>>>
|
||||
val downloads: Flow<Collection<ProgressJob<DownloadManager.State>>>
|
||||
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<AppSettings>()
|
||||
if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
|
||||
if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
|
||||
CheckBoxAlertDialog.Builder(context)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.network_consumption_warning)
|
||||
|
||||
@@ -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<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
|
||||
|
||||
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<Deferred<File>>()
|
||||
class PageLoader : KoinComponent, Closeable {
|
||||
|
||||
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 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) {
|
||||
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<File> {
|
||||
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<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.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<MangaChapter>()
|
||||
|
||||
val pageLoader = PageLoader()
|
||||
|
||||
val readerMode = MutableLiveData<ReaderMode>()
|
||||
val onPageSaved = SingleLiveEvent<Uri?>()
|
||||
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 <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 {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<B : ViewBinding>(
|
||||
|
||||
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.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<B : ViewBinding> : BaseFragment<B>() {
|
||||
|
||||
protected val viewModel by sharedViewModel<ReaderViewModel>()
|
||||
protected val loader by lazy(LazyThreadSafetyMode.NONE) {
|
||||
PageLoader(lifecycleScope, get(), get())
|
||||
}
|
||||
private var stateToSave: ReaderState? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -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<Float>) = 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)
|
||||
}
|
||||
}
|
||||
@@ -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<FragmentReaderStandardBinding>() {
|
||||
@@ -27,7 +30,7 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
|
||||
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
|
||||
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<FragmentReaderWebtoonBinding>() {
|
||||
|
||||
@@ -26,7 +29,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
|
||||
|
||||
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
|
||||
|
||||
@@ -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.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<Network> { 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 <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.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 ->
|
||||
|
||||
@@ -166,6 +166,7 @@ inline fun <reified T> 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) {
|
||||
|
||||
@@ -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
|
||||
android:id="@+id/progressBar"
|
||||
android:indeterminate="true"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true"
|
||||
android:max="100" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_number"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|bottom"
|
||||
android:layout_margin="8dp"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
tools:text="5" />
|
||||
|
||||
<LinearLayout
|
||||
@@ -39,8 +40,7 @@
|
||||
android:layout_marginEnd="60dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_error"
|
||||
@@ -52,7 +52,7 @@
|
||||
app:drawableTopCompat="@drawable/ic_error_large"
|
||||
tools:text="@tools:sample/lorem[6]" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
<Button
|
||||
android:id="@+id/button_retry"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:indeterminate="true"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true"
|
||||
android:max="100" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_error"
|
||||
|
||||
@@ -29,4 +29,9 @@
|
||||
<item>@string/screenshots_block_nsfw</item>
|
||||
<item>@string/screenshots_block_all</item>
|
||||
</string-array>
|
||||
<string-array name="network_policy">
|
||||
<item>@string/always</item>
|
||||
<item>@string/only_using_wifi</item>
|
||||
<item>@string/never</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -24,4 +24,9 @@
|
||||
<item>block_nsfw</item>
|
||||
<item>block_all</item>
|
||||
</string-array>
|
||||
<string-array name="values_network_policy">
|
||||
<item>1</item>
|
||||
<item>2</item>
|
||||
<item>0</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -261,4 +261,8 @@
|
||||
<string name="reset_filter">Reset filter</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="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>
|
||||
@@ -47,4 +47,13 @@
|
||||
app:iconSpaceReserved="false"
|
||||
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>
|
||||
Reference in New Issue
Block a user