From d443da4dcce22d90b561ee0cbf915894cbd43308 Mon Sep 17 00:00:00 2001 From: Jays2Kings Date: Tue, 14 Feb 2023 03:08:05 -0500 Subject: [PATCH] Convert ReaderPresenter to ReaderViewModel Also fixed certain sources without a contenttype not loading Also remove HTTP 103 interceptor Also upgrade some serialization libraries Look a lot of stuff got taken from upstream if you're reading this commit and you work on main your code is probably is in this commit Co-Authored-By: arkon <4098258+arkon@users.noreply.github.com> --- app/build.gradle.kts | 7 +- .../data/download/DownloadManager.kt | 37 +- .../tachiyomi/data/download/Downloader.kt | 17 +- .../data/download/model/DownloadQueue.kt | 28 +- .../data/updater/AppUpdateService.kt | 4 +- .../kanade/tachiyomi/network/NetworkHelper.kt | 3 - .../tachiyomi/network/OkHttpExtensions.kt | 33 +- .../tachiyomi/network/ProgressResponseBody.kt | 4 +- .../network/interceptor/Http103Interceptor.kt | 110 ---- .../kanade/tachiyomi/source/SourceManager.kt | 2 +- .../eu/kanade/tachiyomi/source/model/Page.kt | 55 +- .../tachiyomi/source/online/HttpSource.kt | 16 +- .../source/online/HttpSourceFetcher.kt | 4 +- .../tachiyomi/ui/reader/ReaderActivity.kt | 301 ++++++---- ...{ReaderPresenter.kt => ReaderViewModel.kt} | 523 +++++++++--------- .../ui/reader/chapter/ReaderChapterSheet.kt | 30 +- .../ui/reader/loader/ChapterLoader.kt | 61 +- .../ui/reader/loader/DirectoryPageLoader.kt | 23 +- .../ui/reader/loader/DownloadPageLoader.kt | 95 ++-- .../ui/reader/loader/EpubPageLoader.kt | 21 +- .../ui/reader/loader/HttpPageLoader.kt | 207 +++---- .../tachiyomi/ui/reader/loader/PageLoader.kt | 13 +- .../ui/reader/loader/RarPageLoader.kt | 21 +- .../ui/reader/loader/ZipPageLoader.kt | 22 +- .../tachiyomi/ui/reader/model/InsertPage.kt | 2 +- .../ui/reader/settings/ReaderGeneralView.kt | 10 +- .../ui/reader/settings/ReaderPagedView.kt | 8 +- .../settings/TabbedReaderSettingsSheet.kt | 2 +- .../tachiyomi/ui/reader/viewer/pager/Pager.kt | 12 + .../ui/reader/viewer/pager/PagerPageHolder.kt | 236 ++++---- .../viewer/pager/PagerTransitionHolder.kt | 2 +- .../ui/reader/viewer/pager/PagerViewer.kt | 1 + .../viewer/webtoon/WebtoonPageHolder.kt | 96 ++-- .../viewer/webtoon/WebtoonTransitionHolder.kt | 2 +- .../kanade/tachiyomi/util/storage/EpubFile.kt | 2 +- .../util/system/CoroutinesExtensions.kt | 4 + 36 files changed, 958 insertions(+), 1056 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/reader/{ReaderPresenter.kt => ReaderViewModel.kt} (72%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5ff82e09a..41ecc3c1cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -164,7 +164,7 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion") implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion") - implementation("com.squareup.okio:okio:3.0.0") + implementation("com.squareup.okio:okio:3.3.0") // Chucker val chuckerVersion = "3.5.2" @@ -175,9 +175,10 @@ dependencies { implementation(kotlin("reflect", version = AndroidVersions.kotlin)) // JSON - val kotlinSerialization = "1.3.3" + val kotlinSerialization = "1.4.0" implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${kotlinSerialization}") implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:${kotlinSerialization}") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:${kotlinSerialization}") // JavaScript engine implementation("app.cash.quickjs:quickjs-android:0.9.2") @@ -188,7 +189,7 @@ dependencies { implementation("com.github.junrar:junrar:7.5.0") // HTML parser - implementation("org.jsoup:jsoup:1.14.3") + implementation("org.jsoup:jsoup:1.15.3") // Job scheduling implementation("androidx.work:work-runtime-ktx:2.6.0") diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index ba04686c4a..e2d9ce2e66 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download import android.content.Context import com.hippo.unifile.UniFile import com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download @@ -13,7 +14,6 @@ import eu.kanade.tachiyomi.source.model.Page import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import rx.Observable import timber.log.Timber import uy.kohesive.injekt.injectLazy @@ -177,32 +177,21 @@ class DownloadManager(val context: Context) { * @param source the source of the chapter. * @param manga the manga of the chapter. * @param chapter the downloaded chapter. - * @return an observable containing the list of pages from the chapter. + * @return the list of pages from the chapter. */ - fun buildPageList(source: Source, manga: Manga, chapter: Chapter): Observable> { - return buildPageList(provider.findChapterDir(chapter, manga, source)) - } + fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List { + val chapterDir = provider.findChapterDir(chapter, manga, source) + val files = chapterDir?.listFiles().orEmpty() + .filter { "image" in it.type.orEmpty() } - /** - * Builds the page list of a downloaded chapter. - * - * @param chapterDir the file where the chapter is downloaded. - * @return an observable containing the list of pages from the chapter. - */ - private fun buildPageList(chapterDir: UniFile?): Observable> { - return Observable.fromCallable { - val files = chapterDir?.listFiles().orEmpty() - .filter { "image" in it.type.orEmpty() } - - if (files.isEmpty()) { - throw Exception("Page list is empty") - } - - files.sortedBy { it.name } - .mapIndexed { i, file -> - Page(i, uri = file.uri).apply { status = Page.READY } - } + if (files.isEmpty()) { + throw Exception(context.getString(R.string.no_pages_found)) } + + return files.sortedBy { it.name } + .mapIndexed { i, file -> + Page(i, uri = file.uri).apply { status = Page.State.READY } + } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 1d29dc1922..68e35c45b2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -346,11 +346,14 @@ class Downloader( val pageListObservable = if (download.pages == null) { // Pull page list from network and add them to download object download.source.fetchPageList(download.chapter) - .doOnNext { pages -> + .map { pages -> if (pages.isEmpty()) { throw Exception(context.getString(R.string.no_pages_found)) } - download.pages = pages + // Don't trust index from source + val reIndexedPages = pages.mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) } + download.pages = reIndexedPages + reIndexedPages } } else { // Or if the page list already exists, start from the file @@ -434,13 +437,13 @@ class Downloader( page.uri = file.uri page.progress = 100 download.downloadedImages++ - page.status = Page.READY + page.status = Page.State.READY } .map { page } // Mark this page as error and allow to download the remaining .onErrorReturn { page.progress = 0 - page.status = Page.ERROR + page.status = Page.State.ERROR notifier.onError(it.message, download.chapter.name, download.manga.title) page } @@ -460,13 +463,13 @@ class Downloader( tmpDir: UniFile, filename: String, ): Observable { - page.status = Page.DOWNLOAD_IMAGE + page.status = Page.State.DOWNLOAD_IMAGE page.progress = 0 return source.fetchImage(page) .map { response -> val file = tmpDir.createFile("$filename.tmp") try { - response.body!!.source().saveTo(file.openOutputStream()) + response.body.source().saveTo(file.openOutputStream()) val extension = getImageExtension(response, file) file.renameTo("$filename.$extension") } catch (e: Exception) { @@ -515,7 +518,7 @@ class Downloader( */ private fun getImageExtension(response: Response, file: UniFile): String { // Read content type if available. - val mime = response.body?.contentType()?.let { ct -> "${ct.type}/${ct.subtype}" } + val mime = response.body.contentType()?.let { ct -> "${ct.type}/${ct.subtype}" } // Else guess from the uri. ?: context.contentResolver.getType(file.uri) // Else read magic numbers. diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt index 11139d507a..e58834dd8f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt @@ -4,7 +4,9 @@ import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadStore -import eu.kanade.tachiyomi.source.model.Page +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import rx.subjects.PublishSubject import java.util.concurrent.CopyOnWriteArrayList @@ -20,6 +22,8 @@ class DownloadQueue( private val downloadListeners = mutableListOf() + private var scope = MainScope() + fun addAll(downloads: List) { downloads.forEach { download -> download.setStatusSubject(statusSubject) @@ -79,13 +83,15 @@ class DownloadQueue( if (download.status == Download.State.DOWNLOADING) { if (download.pages != null) { for (page in download.pages!!) - page.setStatusCallback { - callListeners(download) + scope.launch { + page.statusFlow.collectLatest { + callListeners(download) + } } } callListeners(download) } else if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) { - setPagesSubject(download.pages, null) +// setPagesSubject(download.pages, null) if (download.status == Download.State.ERROR) { callListeners(download) } @@ -98,13 +104,13 @@ class DownloadQueue( downloadListeners.forEach { it.updateDownload(download) } } - private fun setPagesSubject(pages: List?, subject: PublishSubject?) { - if (pages != null) { - for (page in pages) { - page.setStatusSubject(subject) - } - } - } +// private fun setPagesSubject(pages: List?, subject: PublishSubject?) { +// if (pages != null) { +// for (page in pages) { +// page.setStatusSubject(subject) +// } +// } +// } fun addListener(listener: DownloadListener) { downloadListeners.add(listener) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt index 692e6a57ac..2748d98200 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt @@ -18,7 +18,7 @@ import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.network.await -import eu.kanade.tachiyomi.network.newCallWithProgress +import eu.kanade.tachiyomi.network.newCachelessCallWithProgress import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.system.acquireWakeLock @@ -141,7 +141,7 @@ class AppUpdateService : Service() { try { // Download the new update. - val call = network.client.newCallWithProgress(GET(url), progressListener) + val call = network.client.newCachelessCallWithProgress(GET(url), progressListener) runningCall = call val response = call.await() diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index 60de4a2a73..358048ffd9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -6,7 +6,6 @@ import com.chuckerteam.chucker.api.ChuckerInterceptor import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor -import eu.kanade.tachiyomi.network.interceptor.Http103Interceptor import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor import okhttp3.Cache import okhttp3.OkHttpClient @@ -25,7 +24,6 @@ class NetworkHelper(val context: Context) { val cookieManager = AndroidCookieJar() private val userAgentInterceptor by lazy { UserAgentInterceptor() } - private val http103Interceptor by lazy { Http103Interceptor(context) } private val cloudflareInterceptor by lazy { CloudflareInterceptor(context) } private val baseClientBuilder: OkHttpClient.Builder @@ -36,7 +34,6 @@ class NetworkHelper(val context: Context) { .readTimeout(30, TimeUnit.SECONDS) .callTimeout(2, TimeUnit.MINUTES) .addInterceptor(userAgentInterceptor) - .addNetworkInterceptor(http103Interceptor) .apply { if (BuildConfig.DEBUG) { addInterceptor( diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 82ab5856bb..f651317dc9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -1,9 +1,12 @@ package eu.kanade.tachiyomi.network -import coil.network.HttpException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.serialization.decodeFromString +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json +import kotlinx.serialization.json.okio.decodeFromBufferedSource +import kotlinx.serialization.serializer import okhttp3.Call import okhttp3.Callback import okhttp3.MediaType.Companion.toMediaType @@ -14,10 +17,9 @@ import rx.Observable import rx.Producer import rx.Subscription import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.fullType +import uy.kohesive.injekt.api.get import java.io.IOException import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException val jsonMime = "application/json; charset=utf-8".toMediaType() @@ -38,9 +40,9 @@ fun Call.asObservable(): Observable { subscriber.onNext(response) subscriber.onCompleted() } - } catch (error: Exception) { + } catch (e: Exception) { if (!subscriber.isUnsubscribed) { - subscriber.onError(error) + subscriber.onError(e) } } } @@ -60,6 +62,7 @@ fun Call.asObservable(): Observable { } // Based on https://github.com/gildor/kotlin-coroutines-okhttp +@OptIn(ExperimentalCoroutinesApi::class) private suspend fun Call.await(callStack: Array): Response { return suspendCancellableCoroutine { continuation -> val callback = @@ -109,18 +112,18 @@ fun Call.asObservableSuccess(): Observable { return asObservable().doOnNext { response -> if (!response.isSuccessful) { response.close() - throw Exception("HTTP error ${response.code}") + throw HttpException(response.code) } } } -fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { +fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call { val progressClient = newBuilder() .cache(null) .addNetworkInterceptor { chain -> val originalResponse = chain.proceed(chain.request()) originalResponse.newBuilder() - .body(ProgressResponseBody(originalResponse.body!!, listener)) + .body(ProgressResponseBody(originalResponse.body, listener)) .build() } .build() @@ -129,11 +132,13 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene } inline fun Response.parseAs(): T { - // Avoiding Injekt.get() due to compiler issues - val json = Injekt.getInstance(fullType().type) - this.use { - val responseBody = it.body?.string().orEmpty() - return json.decodeFromString(responseBody) + return decodeFromJsonResponse(serializer(), this) +} + +@OptIn(ExperimentalSerializationApi::class) +fun decodeFromJsonResponse(deserializer: DeserializationStrategy, response: Response): T { + return response.body.source().use { + Injekt.get().decodeFromBufferedSource(deserializer, it) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt index ff56520b55..72248f17b7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/ProgressResponseBody.kt @@ -15,8 +15,8 @@ class ProgressResponseBody(private val responseBody: ResponseBody, private val p source(responseBody.source()).buffer() } - override fun contentType(): MediaType { - return responseBody.contentType()!! + override fun contentType(): MediaType? { + return responseBody.contentType() } override fun contentLength(): Long { diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt deleted file mode 100644 index b8485539a0..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/network/interceptor/Http103Interceptor.kt +++ /dev/null @@ -1,110 +0,0 @@ -package eu.kanade.tachiyomi.network.interceptor - -import android.annotation.SuppressLint -import android.content.Context -import android.webkit.JavascriptInterface -import android.webkit.WebView -import androidx.core.content.ContextCompat -import eu.kanade.tachiyomi.util.system.WebViewClientCompat -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody -import timber.log.Timber -import java.io.IOException -import java.util.concurrent.CountDownLatch - -// TODO: Remove when OkHttp can handle HTTP 103 responses -class Http103Interceptor(context: Context) : WebViewInterceptor(context) { - - private val executor = ContextCompat.getMainExecutor(context) - - override fun shouldIntercept(response: Response): Boolean { - return response.code == 103 - } - - override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response { - Timber.d("Proceeding with WebView for request $request") - try { - return proceedWithWebView(request, response) - } catch (e: Exception) { - throw IOException(e) - } - } - - @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") - private fun proceedWithWebView(originalRequest: Request, originalResponse: Response): Response { - // We need to lock this thread until the WebView loads the page, because - // OkHttp doesn't support asynchronous interceptors. - val latch = CountDownLatch(1) - - val jsInterface = JsInterface(latch) - - var webview: WebView? = null - - var exception: Exception? = null - - val requestUrl = originalRequest.url.toString() - val headers = parseHeaders(originalRequest.headers) - - executor.execute { - webview = createWebView(originalRequest) - webview?.addJavascriptInterface(jsInterface, "android") - - webview?.webViewClient = object : WebViewClientCompat() { - override fun onPageFinished(view: WebView, url: String) { - view.evaluateJavascript(jsScript) {} - } - - override fun onReceivedErrorCompat( - view: WebView, - errorCode: Int, - description: String?, - failingUrl: String, - isMainFrame: Boolean, - ) { - if (isMainFrame) { - exception = Exception("Error $errorCode - $description") - latch.countDown() - } - } - } - - webview?.loadUrl(requestUrl, headers) - } - - latch.awaitFor30Seconds() - - executor.execute { - webview?.run { - stopLoading() - destroy() - } - } - - exception?.let { throw it } - - val responseHtml = jsInterface.responseHtml ?: throw Exception("Couldn't fetch site through webview") - - return originalResponse.newBuilder() - .code(200) - .protocol(Protocol.HTTP_1_1) - .message("OK") - .body(responseHtml.toResponseBody(htmlMediaType)) - .build() - } -} - -internal class JsInterface(private val latch: CountDownLatch, var responseHtml: String? = null) { - @Suppress("UNUSED") - @JavascriptInterface - fun passPayload(passedPayload: String) { - responseHtml = passedPayload - latch.countDown() - } -} - -private const val jsScript = "window.android.passPayload(document.querySelector('html').outerHTML)" -private val htmlMediaType = "text/html".toMediaType() diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index 7ad6fbeb0d..366e7e3f1a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -121,7 +121,7 @@ open class SourceManager(private val context: Context) { return name } - private fun getSourceNotInstalledException(): Exception { + fun getSourceNotInstalledException(): Exception { return SourceNotFoundException( context.getString( R.string.source_not_installed_, diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt index 74d5c0a0c7..7ce18934f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt @@ -2,8 +2,12 @@ package eu.kanade.tachiyomi.source.model import android.net.Uri import eu.kanade.tachiyomi.network.ProgressListener -import rx.subjects.Subject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +@Serializable open class Page( val index: Int, val url: String = "", @@ -14,25 +18,28 @@ open class Page( val number: Int get() = index + 1 - @Transient @Volatile - var status: Int = 0 + @Transient + private val _statusFlow = MutableStateFlow(State.QUEUE) + + @Transient + val statusFlow = _statusFlow.asStateFlow() + var status: State + get() = _statusFlow.value set(value) { - field = value - statusSubject?.onNext(value) - statusCallback?.invoke(this) + _statusFlow.value = value } - @Transient @Volatile - var progress: Int = 0 + @Transient + private val _progressFlow = MutableStateFlow(0) + + @Transient + val progressFlow = _progressFlow.asStateFlow() + var progress: Int + get() = _progressFlow.value set(value) { - field = value - statusCallback?.invoke(this) + _progressFlow.value = value } - @Transient private var statusSubject: Subject? = null - - @Transient private var statusCallback: ((Page) -> Unit)? = null - override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { progress = if (contentLength > 0) { (100 * bytesRead / contentLength).toInt() @@ -41,19 +48,11 @@ open class Page( } } - fun setStatusSubject(subject: Subject?) { - this.statusSubject = subject - } - - fun setStatusCallback(f: ((Page) -> Unit)?) { - statusCallback = f - } - - companion object { - const val QUEUE = 0 - const val LOAD_PAGE = 1 - const val DOWNLOAD_IMAGE = 2 - const val READY = 3 - const val ERROR = 4 + enum class State { + QUEUE, + LOAD_PAGE, + DOWNLOAD_IMAGE, + READY, + ERROR, } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt index 8692b308e0..77bf9059c2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -5,7 +5,8 @@ import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.asObservableSuccess -import eu.kanade.tachiyomi.network.newCallWithProgress +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.newCachelessCallWithProgress import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage @@ -305,10 +306,21 @@ abstract class HttpSource : CatalogueSource { * @param page the page whose source image has to be downloaded. */ fun fetchImage(page: Page): Observable { - return client.newCallWithProgress(imageRequest(page), page) + return client.newCachelessCallWithProgress(imageRequest(page), page) .asObservableSuccess() } + /** + * Returns the response of the source image. + * + * @param page the page whose source image has to be downloaded. + */ + suspend fun getImage(page: Page): Response { + // images will be cached or saved manually, so don't take up network cache + return client.newCachelessCallWithProgress(imageRequest(page), page) + .awaitSuccess() + } + /** * Returns the request for getting the source image. Override only if it's needed to override * the url, send different headers or request method like POST. diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt index 7b3ea4bdec..6b5e4a6381 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/HttpSourceFetcher.kt @@ -4,9 +4,9 @@ import eu.kanade.tachiyomi.source.model.Page import rx.Observable fun HttpSource.getImageUrl(page: Page): Observable { - page.status = Page.LOAD_PAGE + page.status = Page.State.LOAD_PAGE return fetchImageUrl(page) - .doOnError { page.status = Page.ERROR } + .doOnError { page.status = Page.State.ERROR } .onErrorReturn { null } .doOnNext { page.imageUrl = it } .map { page } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index e11b124b1d..4b81afa34b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -30,6 +30,7 @@ import android.view.WindowManager import android.view.animation.AnimationUtils import androidx.activity.OnBackPressedCallback import androidx.activity.addCallback +import androidx.activity.viewModels import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat @@ -64,18 +65,17 @@ import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn import eu.kanade.tachiyomi.data.preference.toggle import eu.kanade.tachiyomi.databinding.ReaderActivityBinding import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet -import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity +import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.SearchActivity -import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.AddToLibraryFirst -import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Error -import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Success +import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.AddToLibraryFirst +import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Error +import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Success import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters @@ -104,12 +104,14 @@ import eu.kanade.tachiyomi.util.system.isInNightMode import eu.kanade.tachiyomi.util.system.isLTR import eu.kanade.tachiyomi.util.system.isTablet import eu.kanade.tachiyomi.util.system.launchIO +import eu.kanade.tachiyomi.util.system.launchNonCancellable import eu.kanade.tachiyomi.util.system.launchUI import eu.kanade.tachiyomi.util.system.materialAlertDialog import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.rootWindowInsetsCompat import eu.kanade.tachiyomi.util.system.spToPx import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.system.withUIContext import eu.kanade.tachiyomi.util.view.collapse import eu.kanade.tachiyomi.util.view.compatToolTipText import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsetsCompat @@ -123,17 +125,17 @@ import eu.kanade.tachiyomi.widget.doOnStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import nucleus.factory.RequiresPresenter import timber.log.Timber -import uy.kohesive.injekt.injectLazy import java.io.File import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -144,17 +146,13 @@ import kotlin.math.roundToInt /** * Activity containing the reader of Tachiyomi. This activity is mostly a container of the - * viewers, to which calls from the presenter or UI events are delegated. + * viewers, to which calls from the view model or UI events are delegated. */ -@RequiresPresenter(ReaderPresenter::class) -class ReaderActivity : BaseRxActivity() { +class ReaderActivity : BaseActivity() { - lateinit var binding: ReaderActivityBinding + val viewModel by viewModels() - /** - * Preferences helper. - */ - private val preferences by injectLazy() + val scope = lifecycleScope /** * Viewer used to display the pages (pager, webtoon, ...). @@ -263,7 +261,7 @@ class ReaderActivity : BaseRxActivity() { } /** - * Called when the activity is created. Initializes the presenter and configuration. + * Called when the activity is created. Initializes the view model and configuration. */ override fun onCreate(savedInstanceState: Bundle?) { // Setup shared element transitions @@ -300,7 +298,7 @@ class ReaderActivity : BaseRxActivity() { ) backPressedCallback = onBackPressedDispatcher.addCallback { backCallback() } - if (presenter.needsInit()) { + if (viewModel.needsInit()) { fromUrl = handleIntentAction(intent) if (!fromUrl) { val manga = intent.extras!!.getLong("manga", -1) @@ -309,7 +307,15 @@ class ReaderActivity : BaseRxActivity() { finish() return } - presenter.init(manga, chapter) + lifecycleScope.launchNonCancellable { + val initResult = viewModel.init(manga, chapter) + if (!initResult.getOrDefault(false)) { + val exception = initResult.exceptionOrNull() ?: IllegalStateException("Unknown err") + withUIContext { + setInitialChapterError(exception) + } + } + } } else { binding.pleaseWait.isVisible = true } @@ -334,6 +340,58 @@ class ReaderActivity : BaseRxActivity() { SecureActivityDelegate.setSecure(this) } reEnableBackPressedCallBack() + + viewModel.state + .map { it.isLoadingAdjacentChapter } + .distinctUntilChanged() + .onEach(::setProgressDialog) + .launchIn(lifecycleScope) + + viewModel.state + .map { it.manga } + .distinctUntilChanged() + .filterNotNull() + .onEach(::setManga) + .launchIn(lifecycleScope) + + val viewerChapters = viewModel.state.value.viewerChapters + viewModel.state + .map { it.viewerChapters } + .distinctUntilChanged() +// .drop(if (viewerChapters != null) 1 else 0) + .filterNotNull() + .onEach(::setChapters) + .launchIn(lifecycleScope) + viewerChapters?.currChapter?.let { currChapter -> + currChapter.requestedPage = currChapter.chapter.last_page_read + } + + viewModel.eventFlow + .onEach { event -> + when (event) { + ReaderViewModel.Event.ReloadMangaAndChapters -> { + viewModel.manga?.let(::setManga) + viewModel.state.value.viewerChapters?.let(::setChapters) + } + ReaderViewModel.Event.ReloadViewerChapters -> { + viewModel.state.value.viewerChapters?.let(::setChapters) + } + is ReaderViewModel.Event.SetOrientation -> { + setOrientation(event.orientation) + } + is ReaderViewModel.Event.SavedImage -> { + onSaveImageResult(event.result) + } + is ReaderViewModel.Event.ShareImage -> { + onShareImageResult(event.file, event.page) + } + is ReaderViewModel.Event.SetCoverResult -> { + onSetAsCoverResult(event.result) + } + } + } + .launchIn(lifecycleScope) + lifecycleScope.launchUI { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { WindowInfoTracker.getOrCreate(this@ReaderActivity).windowLayoutInfo(this@ReaderActivity) @@ -397,7 +455,9 @@ class ReaderActivity : BaseRxActivity() { } } if (!isChangingConfigurations) { - presenter.onSaveInstanceStateNonConfigurationChange() + viewModel.onSaveInstanceStateNonConfigurationChange() + } else { + viewModel.onSave() } super.onSaveInstanceState(outState) } @@ -541,7 +601,7 @@ class ReaderActivity : BaseRxActivity() { private fun shiftDoublePages() { (viewer as? PagerViewer)?.config?.let { config -> config.shiftDoublePage = !config.shiftDoublePage - presenter.viewerChapters?.let { + viewModel.state.value.viewerChapters?.let { (viewer as? PagerViewer)?.updateShifting() (viewer as? PagerViewer)?.setChaptersDoubleShift(it) invalidateOptionsMenu() @@ -570,13 +630,13 @@ class ReaderActivity : BaseRxActivity() { if (didTransistionFromChapter && visibleChapterRange.isNotEmpty() && MainActivity.chapterIdToExitTo !in visibleChapterRange) { finish() } else { - presenter.onBackPressed() + viewModel.onBackPressed() super.finishAfterTransition() } } override fun finish() { - presenter.onBackPressed() + viewModel.onBackPressed() super.finish() } @@ -670,7 +730,7 @@ class ReaderActivity : BaseRxActivity() { } binding.toolbar.setOnClickListener { - presenter.manga?.id?.let { id -> + viewModel.manga?.id?.let { id -> val intent = SearchActivity.openMangaIntent(this, id) startActivity(intent) } @@ -712,12 +772,12 @@ class ReaderActivity : BaseRxActivity() { setOnClickListener { popupMenu( items = OrientationType.values().map { it.flagValue to it.stringRes }, - selectedItemId = presenter.manga?.orientationType + selectedItemId = viewModel.manga?.orientationType ?: preferences.defaultOrientationType().get(), ) { val newOrientation = OrientationType.fromPreference(itemId) - presenter.setMangaOrientationType(newOrientation.flagValue) + viewModel.setMangaOrientationType(newOrientation.flagValue) updateOrientationShortcut(newOrientation.flagValue) } @@ -740,9 +800,9 @@ class ReaderActivity : BaseRxActivity() { readingMode.setOnClickListener { readingMode -> readingMode.popupMenu( items = ReadingModeType.values().map { it.flagValue to it.stringRes }, - selectedItemId = presenter.manga?.readingModeType, + selectedItemId = viewModel.manga?.readingModeType, ) { - presenter.setMangaReadingMode(itemId) + viewModel.setMangaReadingMode(itemId) } } } @@ -757,53 +817,9 @@ class ReaderActivity : BaseRxActivity() { binding.chaptersSheet.shiftPageButton.setOnClickListener { shiftDoublePages() } - binding.readerNav.leftChapter.setOnClickListener { - if (isLoading) { - return@setOnClickListener - } - isScrollingThroughPagesOrChapters = true - val result = if (viewer is R2LPagerViewer) { - presenter.loadNextChapter() - } else { - presenter.loadPreviousChapter() - } - if (result) { - binding.readerNav.leftChapter.isInvisible = true - binding.readerNav.leftProgress.isVisible = true - } else { - toast( - if (viewer is R2LPagerViewer) { - R.string.theres_no_next_chapter - } else { - R.string.theres_no_previous_chapter - }, - ) - } - } - binding.readerNav.rightChapter.setOnClickListener { - if (isLoading) { - return@setOnClickListener - } - isScrollingThroughPagesOrChapters = true - val result = if (viewer !is R2LPagerViewer) { - presenter.loadNextChapter() - } else { - presenter.loadPreviousChapter() - } - if (result) { - binding.readerNav.rightChapter.isInvisible = true - binding.readerNav.rightProgress.isVisible = true - } else { - toast( - if (viewer !is R2LPagerViewer) { - R.string.theres_no_next_chapter - } else { - R.string.theres_no_previous_chapter - }, - ) - } - } + binding.readerNav.leftChapter.setOnClickListener { loadAdjacentChapter(false) } + binding.readerNav.rightChapter.setOnClickListener { loadAdjacentChapter(true) } binding.touchView.setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_DOWN) { @@ -874,7 +890,7 @@ class ReaderActivity : BaseRxActivity() { val pageNumber = (value + 1).roundToInt() (viewer as? PagerViewer)?.let { if (it.config.doublePages || it.config.splitPages) { - if (it.hasExtraPage(value.roundToInt(), presenter.getCurrentChapter())) { + if (it.hasExtraPage(value.roundToInt(), viewModel.getCurrentChapter())) { val invertDoublePage = (viewer as? PagerViewer)?.config?.invertDoublePages ?: false return@setLabelFormatter if (!binding.readerNav.pageSeekbar.isRTL.xor(invertDoublePage)) { "$pageNumber-${pageNumber + 1}" @@ -951,6 +967,47 @@ class ReaderActivity : BaseRxActivity() { } } + private fun loadAdjacentChapter(rightButton: Boolean) { + if (isLoading) { + return + } + isScrollingThroughPagesOrChapters = true + lifecycleScope.launch { + val getNextChapter = (viewer is R2LPagerViewer).xor(rightButton) + val adjChapter = viewModel.adjacentChapter(getNextChapter) + if (adjChapter != null) { + if (rightButton) { + binding.readerNav.rightChapter.isInvisible = true + binding.readerNav.rightProgress.isVisible = true + } else { + binding.readerNav.leftChapter.isInvisible = true + binding.readerNav.leftProgress.isVisible = true + } + loadChapter(adjChapter) + } else { + toast( + if (getNextChapter) { + R.string.theres_no_next_chapter + } else { + R.string.theres_no_previous_chapter + }, + ) + } + } + } + + suspend fun loadChapter(chapter: Chapter) { + loadChapter(ReaderChapter(chapter)) + } + + private suspend fun loadChapter(chapter: ReaderChapter) { + val lastPage = viewModel.loadChapter(chapter) ?: return + launchUI { + moveToPageIndex(lastPage, false, chapterChange = true) + } + refreshChapters() + } + fun setNavColor(insets: WindowInsetsCompat) { sheetManageNavColor = when { isSplitScreen -> { @@ -1079,13 +1136,13 @@ class ReaderActivity : BaseRxActivity() { } /** - * Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer + * Called from the view model when a manga is ready. Used to instantiate the appropriate viewer * and the binding.toolbar title. */ fun setManga(manga: Manga) { val prevViewer = viewer val noDefault = manga.viewer_flags == -1 - val mangaViewer = presenter.getMangaReadingMode() + val mangaViewer = viewModel.getMangaReadingMode() val newViewer = when (mangaViewer) { ReadingModeType.LEFT_TO_RIGHT.flagValue -> L2RPagerViewer(this) ReadingModeType.VERTICAL.flagValue -> VerticalPagerViewer(this) @@ -1094,8 +1151,8 @@ class ReaderActivity : BaseRxActivity() { else -> R2LPagerViewer(this) } - if (noDefault && presenter.manga?.readingModeType!! > 0 && - presenter.manga?.readingModeType!! != preferences.defaultReadingMode() + if (noDefault && viewModel.manga?.readingModeType!! > 0 && + viewModel.manga?.readingModeType!! != preferences.defaultReadingMode() ) { snackbar = binding.readerLayout.snack( getString( @@ -1112,7 +1169,7 @@ class ReaderActivity : BaseRxActivity() { 4000, ) { setAction(R.string.use_default) { - presenter.setMangaReadingMode(0) + viewModel.setMangaReadingMode(0) } } } @@ -1122,10 +1179,10 @@ class ReaderActivity : BaseRxActivity() { ) { // Wait until transition is complete to avoid crash on API 26 window.sharedElementEnterTransition.addListener( - onEnd = { setOrientation(presenter.getMangaOrientationType()) }, + onEnd = { setOrientation(viewModel.getMangaOrientationType()) }, ) } else { - setOrientation(presenter.getMangaOrientationType()) + setOrientation(viewModel.getMangaOrientationType()) } // Destroy previous viewer if there was one @@ -1161,7 +1218,7 @@ class ReaderActivity : BaseRxActivity() { }, ) - binding.toolbar.title = manga.title + supportActionBar?.title = manga.title binding.readerNav.pageSeekbar.isRTL = newViewer is R2LPagerViewer @@ -1170,19 +1227,19 @@ class ReaderActivity : BaseRxActivity() { invalidateOptionsMenu() updateCropBordersShortcut() updateBottomShortcuts() - val viewerMode = ReadingModeType.fromPreference(presenter?.manga?.readingModeType ?: 0) + val viewerMode = ReadingModeType.fromPreference(viewModel.state.value.manga?.readingModeType ?: 0) binding.chaptersSheet.readingMode.setImageResource(viewerMode.iconRes) startPostponedEnterTransition() } override fun onPause() { - presenter.saveCurrentChapterReadingProgress() + viewModel.saveCurrentChapterReadingProgress() super.onPause() } override fun onResume() { super.onResume() - presenter.setReadStartTime() + viewModel.setReadStartTime() } fun reloadChapters(doublePages: Boolean, force: Boolean = false) { @@ -1196,7 +1253,7 @@ class ReaderActivity : BaseRxActivity() { pViewer.config.splitPages = preferences.automaticSplitsPage().get() && !pViewer.config.doublePages } } - val currentChapter = presenter.getCurrentChapter() + val currentChapter = viewModel.getCurrentChapter() if (doublePages) { // If we're moving from singe to double, we want the current page to be the first page pViewer.config.shiftDoublePage = ( @@ -1207,14 +1264,14 @@ class ReaderActivity : BaseRxActivity() { ) ) % 2 != 0 } - presenter.viewerChapters?.let { + viewModel.state.value.viewerChapters?.let { pViewer.setChaptersDoubleShift(it) } invalidateOptionsMenu() } /** - * Called from the presenter whenever a new [viewerChapters] have been set. It delegates the + * Called from the view model whenever a new [viewerChapters] have been set. It delegates the * method to the current viewer, but also set the subtitle on the binding.toolbar. */ fun setChapters(viewerChapters: ViewerChapters) { @@ -1246,7 +1303,7 @@ class ReaderActivity : BaseRxActivity() { viewer?.setChapters(viewerChapters) intentPageNumber?.let { moveToPageIndex(it) } intentPageNumber = null - binding.toolbar.subtitle = if (presenter.manga!!.hideChapterTitle(preferences)) { + binding.toolbar.subtitle = if (viewModel.manga!!.hideChapterTitle(preferences)) { val number = decimalFormat.format(viewerChapters.currChapter.chapter.chapter_number.toDouble()) getString(R.string.chapter_, number) } else { @@ -1268,7 +1325,7 @@ class ReaderActivity : BaseRxActivity() { } /** - * Called from the presenter if the initial load couldn't load the pages of the chapter. In + * Called from the view model if the initial load couldn't load the pages of the chapter. In * this case the activity is closed and a toast is shown to the user. */ fun setInitialChapterError(error: Throwable) { @@ -1278,7 +1335,7 @@ class ReaderActivity : BaseRxActivity() { } /** - * Called from the presenter whenever it's loading the next or previous chapter. It shows or + * Called from the view model whenever it's loading the next or previous chapter. It shows or * dismisses a non-cancellable dialog to prevent user interaction according to the value of * [show]. This is only used when the next/previous buttons on the binding.toolbar are clicked; the * other cases are handled with chapter transitions on the viewers and chapter preloading. @@ -1308,7 +1365,7 @@ class ReaderActivity : BaseRxActivity() { */ fun moveToPageIndex(index: Int, animated: Boolean = true, chapterChange: Boolean = false) { val viewer = viewer ?: return - val currentChapter = presenter.getCurrentChapter() ?: return + val currentChapter = viewModel.getCurrentChapter() ?: return val page = currentChapter.pages?.getOrNull(index) ?: return viewer.moveToPage(page, animated) if (chapterChange) { @@ -1322,11 +1379,11 @@ class ReaderActivity : BaseRxActivity() { /** * Called from the viewer whenever a [page] is marked as active. It updates the values of the - * bottom menu and delegates the change to the presenter. + * bottom menu and delegates the change to the view model. */ @SuppressLint("SetTextI18n") fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) { - presenter.onPageSelected(page, hasExtraPage) + viewModel.onPageSelected(page, hasExtraPage) val pages = page.chapter.pages ?: return val currentPage = if (hasExtraPage) { @@ -1449,9 +1506,9 @@ class ReaderActivity : BaseRxActivity() { Color.BLACK } if (item == 6) { - presenter.shareImages(page, secondPage, isLTR, bg) + viewModel.shareImages(page, secondPage, isLTR, bg) } else { - presenter.saveImages(page, secondPage, isLTR, bg) + viewModel.saveImages(page, secondPage, isLTR, bg) } } } @@ -1468,7 +1525,9 @@ class ReaderActivity : BaseRxActivity() { * the viewer is reaching the beginning or end of a chapter or the transition page is active. */ fun requestPreloadChapter(chapter: ReaderChapter) { - presenter.preloadChapter(chapter) + lifecycleScope.launch { + viewModel.preloadChapter(chapter) + } } /** @@ -1489,15 +1548,15 @@ class ReaderActivity : BaseRxActivity() { } /** - * Called from the page sheet. It delegates the call to the presenter to do some IO, which + * Called from the page sheet. It delegates the call to the view model to do some IO, which * will call [onShareImageResult] with the path the image was saved on when it's ready. */ private fun shareImage(page: ReaderPage) { - presenter.shareImage(page) + viewModel.shareImage(page) } private fun showSetCoverPrompt(page: ReaderPage) { - if (page.status != Page.READY) return + if (page.status != Page.State.READY) return materialAlertDialog() .setMessage(R.string.use_image_as_cover) @@ -1509,11 +1568,11 @@ class ReaderActivity : BaseRxActivity() { } /** - * Called from the presenter when a page is ready to be shared. It shows Android's default + * Called from the view model when a page is ready to be shared. It shows Android's default * sharing tool. */ fun onShareImageResult(file: File, page: ReaderPage, secondPage: ReaderPage? = null) { - val manga = presenter.manga ?: return + val manga = viewModel.manga ?: return val chapter = page.chapter.chapter val decimalFormat = @@ -1544,28 +1603,28 @@ class ReaderActivity : BaseRxActivity() { override fun onProvideAssistContent(outContent: AssistContent) { super.onProvideAssistContent(outContent) - val chapterUrl = presenter.getChapterUrl() ?: return + val chapterUrl = viewModel.getChapterUrl() ?: return outContent.webUri = Uri.parse(chapterUrl) } /** * Called from the page sheet. It delegates saving the image of the given [page] on external - * storage to the presenter. + * storage to the viewModel. */ private fun saveImage(page: ReaderPage) { - presenter.saveImage(page) + viewModel.saveImage(page) } /** - * Called from the presenter when a page is saved or fails. It shows a message or logs the + * Called from the view model when a page is saved or fails. It shows a message or logs the * event depending on the [result]. */ - fun onSaveImageResult(result: ReaderPresenter.SaveImageResult) { + fun onSaveImageResult(result: ReaderViewModel.SaveImageResult) { when (result) { - is ReaderPresenter.SaveImageResult.Success -> { + is ReaderViewModel.SaveImageResult.Success -> { toast(R.string.picture_saved) } - is ReaderPresenter.SaveImageResult.Error -> { + is ReaderViewModel.SaveImageResult.Error -> { Timber.e(result.error) } } @@ -1573,17 +1632,17 @@ class ReaderActivity : BaseRxActivity() { /** * Called from the page sheet. It delegates setting the image of the given [page] as the - * cover to the presenter. + * cover to the viewModel. */ private fun setAsCover(page: ReaderPage) { - presenter.setAsCover(page) + viewModel.setAsCover(page) } /** - * Called from the presenter when a page is set as cover or fails. It shows a different message + * Called from the view model when a page is set as cover or fails. It shows a different message * depending on the [result]. */ - fun onSetAsCoverResult(result: ReaderPresenter.SetAsCoverResult) { + fun onSetAsCoverResult(result: ReaderViewModel.SetAsCoverResult) { toast( when (result) { Success -> R.string.cover_updated @@ -1662,7 +1721,7 @@ class ReaderActivity : BaseRxActivity() { private fun handleIntentAction(intent: Intent): Boolean { val uri = intent.data ?: return false - if (!presenter.canLoadUrl(uri)) { + if (!viewModel.canLoadUrl(uri)) { openInBrowser(intent.data!!.toString(), true) finishAfterTransition() return true @@ -1670,8 +1729,8 @@ class ReaderActivity : BaseRxActivity() { setMenuVisibility(visible = false, animate = true) scope.launch(Dispatchers.IO) { try { - intentPageNumber = presenter.intentPageNumber(uri) - presenter.loadChapterURL(uri) + intentPageNumber = viewModel.intentPageNumber(uri) + viewModel.loadChapterURL(uri) } catch (e: Exception) { withContext(Dispatchers.Main) { setInitialChapterError(e) @@ -1682,14 +1741,14 @@ class ReaderActivity : BaseRxActivity() { } private fun openMangaInBrowser() { - val source = presenter.getSource() ?: return - val chapterUrl = presenter.getChapterUrl() ?: return + val source = viewModel.getSource() ?: return + val chapterUrl = viewModel.getChapterUrl() ?: return val intent = WebViewActivity.newIntent( applicationContext, source.id, chapterUrl, - presenter.manga!!.title, + viewModel.manga!!.title, ) startActivity(intent) } @@ -1719,7 +1778,7 @@ class ReaderActivity : BaseRxActivity() { .drop(1) .onEach { delay(250) - setOrientation(presenter.getMangaOrientationType()) + setOrientation(viewModel.getMangaOrientationType()) } .launchIn(scope) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt similarity index 72% rename from app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index e006c42509..120d34f8b9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -3,10 +3,11 @@ package eu.kanade.tachiyomi.ui.reader import android.app.Application import android.graphics.BitmapFactory import android.net.Uri -import android.os.Bundle import android.os.Environment import androidx.annotation.ColorInt -import com.jakewharton.rxrelay.BehaviorRelay +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper @@ -14,6 +15,7 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications @@ -23,7 +25,6 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem import eu.kanade.tachiyomi.ui.reader.chapter.ReaderChapterItem import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader @@ -43,43 +44,61 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.executeOnIO import eu.kanade.tachiyomi.util.system.launchIO -import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.system.launchNonCancellable import eu.kanade.tachiyomi.util.system.localeContext +import eu.kanade.tachiyomi.util.system.withIOContext import eu.kanade.tachiyomi.util.system.withUIContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import rx.Completable -import rx.Observable -import rx.Subscription -import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File import java.util.Date -import java.util.concurrent.TimeUnit +import java.util.concurrent.CancellationException /** * Presenter used by the activity to perform background operations. */ -class ReaderPresenter( +class ReaderViewModel( + private val savedState: SavedStateHandle = SavedStateHandle(), private val db: DatabaseHelper = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get(), -) : BasePresenter() { +) : ViewModel() { + + private val mutableState = MutableStateFlow(State()) + val state = mutableState.asStateFlow() + + private val downloadProvider = DownloadProvider(preferences.context) + + private val eventChannel = Channel() + val eventFlow = eventChannel.receiveAsFlow() /** * The manga loaded in the reader. It can be null when instantiated for a short time. */ - var manga: Manga? = null - private set + val manga: Manga? + get() = state.value.manga val source: Source? get() = manga?.source?.let { sourceManager.getOrStub(it) } @@ -87,7 +106,11 @@ class ReaderPresenter( /** * The chapter id of the currently loaded chapter. Used to restore from process kill. */ - private var chapterId = -1L + private var chapterId = savedState.get("chapter_id") ?: -1L + set(value) { + savedState["chapter_id"] = value + field = value + } /** * The chapter loader for the loaded manga. It'll be null until [manga] is set. @@ -99,25 +122,11 @@ class ReaderPresenter( */ private var chapterReadStartTime: Long? = null - /** - * Subscription to prevent setting chapters as active from multiple threads. - */ - private var activeChapterSubscription: Subscription? = null - - /** - * Relay for currently active viewer chapters. - */ - private val viewerChaptersRelay = BehaviorRelay.create() - - val viewerChapters: ViewerChapters? - get() = viewerChaptersRelay.value - /** * Relay used when loading prev/next chapter needed to lock the UI (with a dialog). */ - private val isLoadingAdjacentChapterRelay = BehaviorRelay.create() private var finished = false - private var chapterDownload: Download? = null + private var chapterToDownload: Download? = null /** * Chapter list for the active manga. It's retrieved lazily and should be accessed for the first @@ -147,27 +156,26 @@ class ReaderPresenter( hasTrackers = tracks.size > 0 } - /** - * Called when the presenter is created. It retrieves the saved active chapter if the process - * was restored. - */ - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - if (savedState != null) { - chapterId = savedState.getLong(::chapterId.name, -1) - } + init { + // To save state + state.map { it.viewerChapters?.currChapter } + .distinctUntilChanged() + .filterNotNull() + .onEach { currentChapter -> + chapterId = currentChapter.chapter.id!! + currentChapter.requestedPage = currentChapter.chapter.last_page_read + } + .launchIn(viewModelScope) } /** * Called when the presenter instance is being saved. It saves the currently active chapter * id and the last page read. */ - override fun onSave(state: Bundle) { - super.onSave(state) + fun onSave() { val currentChapter = getCurrentChapter() if (currentChapter != null) { currentChapter.requestedPage = currentChapter.chapter.last_page_read - state.putLong(::chapterId.name, currentChapter.chapter.id!!) } } @@ -179,11 +187,11 @@ class ReaderPresenter( if (finished) return finished = true deletePendingChapters() - val currentChapters = viewerChaptersRelay.value + val currentChapters = state.value.viewerChapters if (currentChapters != null) { currentChapters.unref() saveReadingProgress(currentChapters.currChapter) - chapterDownload?.let { + chapterToDownload?.let { downloadManager.addDownloadsToStartOfQueue(listOf(it)) } } @@ -209,19 +217,40 @@ class ReaderPresenter( * Initializes this presenter with the given [mangaId] and [initialChapterId]. This method will * fetch the manga from the database and initialize the initial chapter. */ - fun init(mangaId: Long, initialChapterId: Long) { - if (!needsInit()) return + suspend fun init(mangaId: Long, initialChapterId: Long): Result { + if (!needsInit()) return Result.success(true) + return withIOContext { + try { + val manga = db.getManga(mangaId).executeAsBlocking() + if (manga != null) { + mutableState.update { it.copy(manga = manga) } + if (chapterId == -1L) chapterId = initialChapterId - db.getManga(mangaId).asRxObservable() - .first() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { init(it, initialChapterId) } - .subscribeFirst( - { _, _ -> - // Ignore onNext event - }, - ReaderActivity::setInitialChapterError, - ) + checkTrackers(manga) + + NotificationReceiver.dismissNotification( + preferences.context, + manga.id!!.hashCode(), + Notifications.ID_NEW_CHAPTERS, + ) + + val source = sourceManager.getOrStub(manga.source) + val context = Injekt.get() + loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source) + + loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id }) + Result.success(true) + } else { + // Unlikely but okay + Result.success(false) + } + } catch (e: Throwable) { + if (e is CancellationException) { + throw e + } + Result.failure(e) + } + } } suspend fun getChapters(): List { @@ -237,7 +266,7 @@ class ReaderPresenter( ReaderChapterItem( it, manga, - it.id == getCurrentChapter()?.chapter?.id ?: chapterId, + it.id == (getCurrentChapter()?.chapter?.id ?: chapterId), ) } } @@ -245,82 +274,6 @@ class ReaderPresenter( return chapterItems } - /** - * Initializes this presenter with the given [manga] and [initialChapterId]. This method will - * set the chapter loader, view subscriptions and trigger an initial load. - */ - private fun init(manga: Manga, initialChapterId: Long) { - if (!needsInit()) return - - this.manga = manga - if (chapterId == -1L) chapterId = initialChapterId - - checkTrackers(manga) - - NotificationReceiver.dismissNotification( - preferences.context, - manga.id!!.hashCode(), - Notifications.ID_NEW_CHAPTERS, - ) - - val source = sourceManager.getOrStub(manga.source) - loader = ChapterLoader(downloadManager, manga, source) - - Observable.just(manga).subscribeLatestCache(ReaderActivity::setManga) - viewerChaptersRelay.subscribeLatestCache(ReaderActivity::setChapters) - isLoadingAdjacentChapterRelay.subscribeLatestCache(ReaderActivity::setProgressDialog) - - // Read chapterList from an io thread because it's retrieved lazily and would block main. - activeChapterSubscription?.unsubscribe() - activeChapterSubscription = Observable - .fromCallable { chapterList.first { chapterId == it.chapter.id } } - .flatMap { getLoadObservable(loader!!, it) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { _, _ -> - // Ignore onNext event - }, - ReaderActivity::setInitialChapterError, - ) - } - - /** - * Returns an observable that loads the given [chapter] with this [loader]. This observable - * handles main thread synchronization and updating the currently active chapters on - * [viewerChaptersRelay], however callers must ensure there won't be more than one - * subscription active by unsubscribing any existing [activeChapterSubscription] before. - * Callers must also handle the onError event. - */ - private fun getLoadObservable( - loader: ChapterLoader, - chapter: ReaderChapter, - ): Observable { - return loader.loadChapter(chapter) - .andThen( - Observable.fromCallable { - val chapterPos = chapterList.indexOf(chapter) - - ViewerChapters( - chapter, - chapterList.getOrNull(chapterPos - 1), - chapterList.getOrNull(chapterPos + 1), - ) - }, - ) - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { newChapters -> - val oldChapters = viewerChaptersRelay.value - - // Add new references first to avoid unnecessary recycling - newChapters.ref() - oldChapters?.unref() - - chapterDownload = deleteChapterFromDownloadQueue(newChapters.currChapter) - viewerChaptersRelay.call(newChapters) - } - } - fun canLoadUrl(uri: Uri): Boolean { val host = uri.host ?: return false val delegatedSource = sourceManager.getDelegatedSource(host) ?: return false @@ -338,7 +291,7 @@ class ReaderPresenter( @Suppress("DEPRECATION") suspend fun loadChapterURL(url: Uri) { val host = url.host ?: return - val context = view ?: preferences.context + val context = Injekt.get() val delegatedSource = sourceManager.getDelegatedSource(host) ?: error( context.getString(R.string.source_not_installed), ) @@ -357,14 +310,8 @@ class ReaderPresenter( httpSource?.baseUrl?.contains(domainName) == true } } - if (dbChapter?.manga_id != null) { - val dbManga = db.getManga(dbChapter.manga_id!!).executeOnIO() - if (dbManga != null) { - withContext(Dispatchers.Main) { - init(dbManga, dbChapter.id!!) - } - return - } + if (dbChapter?.manga_id?.let { init(it, dbChapter.id!!).isSuccess } == true) { + return } } val info = delegatedSource.fetchMangaFromChapterUrl(url) @@ -377,7 +324,7 @@ class ReaderPresenter( db.getChapters(manga).executeOnIO().find { it.url == chapter.url }?.id if (matchingChapterId != null) { withContext(Dispatchers.Main) { - this@ReaderPresenter.init(manga, matchingChapterId) + this@ReaderViewModel.init(manga.id!!, matchingChapterId) } } else { val chapterId: Long @@ -397,7 +344,7 @@ class ReaderPresenter( ) } withContext(Dispatchers.Main) { - init(manga, chapterId) + init(manga.id!!, chapterId) } } } else { @@ -409,42 +356,86 @@ class ReaderPresenter( * Called when the user changed to the given [chapter] when changing pages from the viewer. * It's used only to set this chapter as active. */ - private fun loadNewChapter(chapter: ReaderChapter) { + private suspend fun loadNewChapter(chapter: ReaderChapter) { val loader = loader ?: return Timber.d("Loading ${chapter.chapter.url}") - activeChapterSubscription?.unsubscribe() - activeChapterSubscription = getLoadObservable(loader, chapter) - .toCompletable() - .onErrorComplete() - .subscribe() - .also(::add) +// activeChapterSubscription?.unsubscribe() +// activeChapterSubscription = getLoadObservable(loader, chapter) +// .toCompletable() +// .onErrorComplete() +// .subscribe() +// .also(::add) + withIOContext { + try { + loadChapter(loader, chapter) + } catch (e: Throwable) { + if (e is CancellationException) { + throw e + } + Timber.e(e) + } + } } - fun loadChapter(chapter: Chapter) { - val loader = loader ?: return + /** + * Loads the given [chapter] with this [loader] and updates the currently active chapters. + * Callers must handle errors. + */ + private suspend fun loadChapter( + loader: ChapterLoader, + chapter: ReaderChapter, + ): ViewerChapters { + loader.loadChapter(chapter) - viewerChaptersRelay.value?.currChapter?.let(::saveReadingProgress) + val chapterPos = chapterList.indexOf(chapter) +// chapter.requestedPage = chapter.chapter.last_page_read + val newChapters = ViewerChapters( + chapter, + chapterList.getOrNull(chapterPos - 1), + chapterList.getOrNull(chapterPos + 1), + ) - Timber.d("Loading ${chapter.url}") + withUIContext { + mutableState.update { + // Add new references first to avoid unnecessary recycling + newChapters.ref() + it.viewerChapters?.unref() - activeChapterSubscription?.unsubscribe() - val lastPage = if (chapter.pages_left <= 1) 0 else chapter.last_page_read - activeChapterSubscription = getLoadObservable(loader, ReaderChapter(chapter)) - .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) } - .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) } - .subscribeFirst( - { view, _ -> - scope.launchUI { - view.moveToPageIndex(lastPage, false, chapterChange = true) - } - view.refreshChapters() - }, - { _, _ -> - // Ignore onError event, viewers handle that state - }, - ) + chapterToDownload = deleteChapterFromDownloadQueue(newChapters.currChapter) + it.copy(viewerChapters = newChapters) + } + } + return newChapters + } + + /** + * Called when the user is going to load the prev/next chapter through the menu button. + */ + suspend fun loadChapter(chapter: ReaderChapter): Int? { + val loader = loader ?: return -1 + + Timber.d("Loading adjacent ${chapter.chapter.url}") + var lastPage: Int? = null + mutableState.update { it.copy(isLoadingAdjacentChapter = true) } + try { + withIOContext { + loadChapter(loader, chapter) + lastPage = + if (chapter.chapter.pages_left <= 1) 0 else chapter.chapter.last_page_read + withUIContext { + } + } + } catch (e: Throwable) { + if (e is CancellationException) { + throw e + } + Timber.e(e) + } finally { + mutableState.update { it.copy(isLoadingAdjacentChapter = false) } + } + return lastPage } fun toggleBookmark(chapter: Chapter) { @@ -456,7 +447,7 @@ class ReaderPresenter( * Called when the viewers decide it's a good time to preload a [chapter] and improve the UX so * that the user doesn't have to wait too long to continue reading. */ - private fun preload(chapter: ReaderChapter) { + private suspend fun preload(chapter: ReaderChapter) { if (chapter.pageLoader is HttpPageLoader) { val manga = manga ?: return val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga) @@ -472,32 +463,22 @@ class ReaderPresenter( Timber.d("Preloading ${chapter.chapter.url}") val loader = loader ?: return - - loader.loadChapter(chapter) - .observeOn(AndroidSchedulers.mainThread()) - // Update current chapters whenever a chapter is preloaded - .doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) } - .onErrorComplete() - .subscribe() - .also(::add) + withIOContext { + try { + loader.loadChapter(chapter) + } catch (e: Throwable) { + if (e is CancellationException) { + throw e + } + return@withIOContext + } + eventChannel.trySend(Event.ReloadViewerChapters) + } } - /** - * Called from the activity to load and set the next chapter as active. - */ - fun loadNextChapter(): Boolean { - val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return false - loadChapter(nextChapter.chapter) - return true - } - - /** - * Called from the activity to load and set the previous chapter as active. - */ - fun loadPreviousChapter(): Boolean { - val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return false - loadChapter(prevChapter.chapter) - return true + fun adjacentChapter(next: Boolean): ReaderChapter? { + val chapters = state.value.viewerChapters + return if (next) chapters?.nextChapter else chapters?.prevChapter } /** @@ -506,7 +487,7 @@ class ReaderPresenter( * [page]'s chapter is different from the currently active. */ fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) { - val currentChapters = viewerChaptersRelay.value ?: return + val currentChapters = state.value.viewerChapters ?: return val selectedChapter = page.chapter @@ -531,7 +512,7 @@ class ReaderPresenter( Timber.d("Setting ${selectedChapter.chapter.url} as active") saveReadingProgress(currentChapters.currChapter) setReadStartTime() - loadNewChapter(selectedChapter) + scope.launch { loadNewChapter(selectedChapter) } } val pages = page.chapter.pages ?: return val inDownloadRange = page.number.toDouble() / pages.size > 0.2 @@ -543,7 +524,7 @@ class ReaderPresenter( private fun downloadNextChapters() { val manga = manga ?: return if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return - val nextChapter = viewerChaptersRelay.value?.nextChapter?.chapter ?: return + val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return val chaptersNumberToDownload = preferences.autoDownloadWhileReading().get() if (chaptersNumberToDownload == 0 || !manga.favorite) return val isNextChapterDownloaded = downloadManager.isChapterDownloaded(nextChapter, manga) @@ -596,10 +577,10 @@ class ReaderPresenter( val removeAfterReadSlots = preferences.removeAfterReadSlots() val chapterToDelete = chapterList.getOrNull(currentChapterPosition - removeAfterReadSlots) - if (removeAfterReadSlots != 0 && chapterDownload != null) { - downloadManager.addDownloadsToStartOfQueue(listOf(chapterDownload!!)) + if (removeAfterReadSlots != 0 && chapterToDownload != null) { + downloadManager.addDownloadsToStartOfQueue(listOf(chapterToDownload!!)) } else { - chapterDownload = null + chapterToDownload = null } // Check if deleting option is enabled and chapter exists if (removeAfterReadSlots != -1 && chapterToDelete != null) { @@ -654,7 +635,7 @@ class ReaderPresenter( /** * Called from the activity to preload the given [chapter]. */ - fun preloadChapter(chapter: ReaderChapter) { + suspend fun preloadChapter(chapter: ReaderChapter) { preload(chapter) } @@ -662,7 +643,7 @@ class ReaderPresenter( * Returns the currently active chapter. */ fun getCurrentChapter(): ReaderChapter? { - return viewerChaptersRelay.value?.currChapter + return state.value.viewerChapters?.currChapter } fun getChapterUrl(): String? { @@ -702,22 +683,25 @@ class ReaderPresenter( */ fun setMangaReadingMode(readingModeType: Int) { val manga = manga ?: return - manga.readingModeType = readingModeType - db.updateViewerFlags(manga).executeAsBlocking() - Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - val currChapters = viewerChaptersRelay.value - if (currChapters != null) { - // Save current page - val currChapter = currChapters.currChapter - currChapter.requestedPage = currChapter.chapter.last_page_read + runBlocking(Dispatchers.IO) { + manga.readingModeType = readingModeType + db.updateViewerFlags(manga).executeAsBlocking() + val currChapters = state.value.viewerChapters + if (currChapters != null) { + // Save current page + val currChapter = currChapters.currChapter + currChapter.requestedPage = currChapter.chapter.last_page_read - // Emit manga and chapters to the new viewer - view.setManga(manga) - view.setChapters(currChapters) + mutableState.update { + it.copy( + manga = db.getManga(manga.id!!).executeAsBlocking(), + viewerChapters = currChapters, + ) } - },) + eventChannel.send(Event.ReloadMangaAndChapters) + } + } } /** @@ -736,18 +720,26 @@ class ReaderPresenter( */ fun setMangaOrientationType(rotationType: Int) { val manga = manga ?: return - manga.orientationType = rotationType + this.manga?.orientationType = rotationType db.updateViewerFlags(manga).executeAsBlocking() Timber.i("Manga orientation is ${manga.orientationType}") - Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) - .subscribeFirst({ view, _ -> - val currChapters = viewerChaptersRelay.value - if (currChapters != null) { - view.setOrientation(getMangaOrientationType()) + viewModelScope.launchIO { +// delay(250) + db.updateViewerFlags(manga).executeAsBlocking() + val currChapters = state.value.viewerChapters + if (currChapters != null) { + mutableState.update { + it.copy( + manga = db.getManga(manga.id!!).executeAsBlocking(), + viewerChapters = currChapters, + ) } - },) + eventChannel.send(Event.SetOrientation(getMangaOrientationType())) + eventChannel.send(Event.ReloadViewerChapters) + } + } } /** @@ -776,7 +768,7 @@ class ReaderPresenter( } /** - * Saves the image of this [page] in the given [directory] and returns the file location. + * Saves the image of [page1] and [page2] in the given [directory] and returns the file location. */ private fun saveImages(page1: ReaderPage, page2: ReaderPage, isLTR: Boolean, @ColorInt bg: Int, directory: File, manga: Manga): File { val stream1 = page1.stream!! @@ -814,7 +806,7 @@ class ReaderPresenter( * There's also a notification to allow sharing the image somewhere else or deleting it. */ fun saveImage(page: ReaderPage) { - if (page.status != Page.READY) return + if (page.status != Page.State.READY) return val manga = manga ?: return val context = Injekt.get() @@ -832,24 +824,23 @@ class ReaderPresenter( } // Copy file in background. - Observable.fromCallable { saveImage(page, destDir, manga) } - .doOnNext { file -> + viewModelScope.launchNonCancellable { + try { + val file = saveImage(page, destDir, manga) DiskUtil.scanMedia(context, file) notifier.onComplete(file) + eventChannel.send(Event.SavedImage(SaveImageResult.Success(file))) + } catch (e: Exception) { + notifier.onError(e.message) + eventChannel.send(Event.SavedImage(SaveImageResult.Error(e))) } - .doOnError { notifier.onError(it.message) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) }, - { view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }, - ) + } } fun saveImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) { scope.launch { - if (firstPage.status != Page.READY) return@launch - if (secondPage.status != Page.READY) return@launch + if (firstPage.status != Page.State.READY) return@launch + if (secondPage.status != Page.State.READY) return@launch val manga = manga ?: return@launch val context = Injekt.get() @@ -870,9 +861,9 @@ class ReaderPresenter( val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga) DiskUtil.scanMedia(context, file) notifier.onComplete(file) - withUIContext { view?.onSaveImageResult(SaveImageResult.Success(file)) } + eventChannel.send(Event.SavedImage(SaveImageResult.Success(file))) } catch (e: Exception) { - withUIContext { view?.onSaveImageResult(SaveImageResult.Error(e)) } + eventChannel.send(Event.SavedImage(SaveImageResult.Error(e))) } } } @@ -885,26 +876,23 @@ class ReaderPresenter( * image will be kept so it won't be taking lots of internal disk space. */ fun shareImage(page: ReaderPage) { - if (page.status != Page.READY) return + if (page.status != Page.State.READY) return val manga = manga ?: return val context = Injekt.get() val destDir = File(context.cacheDir, "shared_image") - Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file - .map { saveImage(page, destDir, manga) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, file -> view.onShareImageResult(file, page) }, - { _, _ -> }, - ) + viewModelScope.launchNonCancellable { + destDir.deleteRecursively() // Keep only the last shared file + val file = saveImage(page, destDir, manga) + eventChannel.send(Event.ShareImage(file, page)) + } } fun shareImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) { scope.launch { - if (firstPage.status != Page.READY) return@launch - if (secondPage.status != Page.READY) return@launch + if (firstPage.status != Page.State.READY) return@launch + if (secondPage.status != Page.State.READY) return@launch val manga = manga ?: return@launch val context = Injekt.get() @@ -912,10 +900,8 @@ class ReaderPresenter( destDir.deleteRecursively() try { val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga) - withUIContext { - view?.onShareImageResult(file, firstPage, secondPage) - } - } catch (e: Exception) { + eventChannel.send(Event.ShareImage(file, firstPage, secondPage)) + } catch (_: Exception) { } } } @@ -924,12 +910,12 @@ class ReaderPresenter( * Sets the image of this [page] as cover and notifies the UI of the result. */ fun setAsCover(page: ReaderPage) { - if (page.status != Page.READY) return + if (page.status != Page.State.READY) return val manga = manga ?: return val stream = page.stream ?: return - Observable - .fromCallable { + viewModelScope.launchNonCancellable { + val result = try { if (manga.isLocal()) { val context = Injekt.get() coverCache.deleteFromCache(manga) @@ -944,13 +930,11 @@ class ReaderPresenter( SetAsCoverResult.AddToLibraryFirst } } + } catch (e: Exception) { + SetAsCoverResult.Error } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeFirst( - { view, result -> view.onSetAsCoverResult(result) }, - { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }, - ) + eventChannel.send(Event.SetCoverResult(result)) + } } /** @@ -1008,4 +992,21 @@ class ReaderPresenter( .subscribeOn(Schedulers.io()) .subscribe() } + + data class State( + val manga: Manga? = null, + val viewerChapters: ViewerChapters? = null, + val isLoadingAdjacentChapter: Boolean = false, + val lastPage: Int? = null, + ) + + sealed class Event { + object ReloadViewerChapters : Event() + object ReloadMangaAndChapters : Event() + data class SetOrientation(val orientation: Int) : Event() + data class SetCoverResult(val result: SetAsCoverResult) : Event() + + data class SavedImage(val result: SaveImageResult) : Event() + data class ShareImage(val file: File, val page: ReaderPage, val extraPage: ReaderPage? = null) : Event() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterSheet.kt index b4cf0d5a68..2cee929dd1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterSheet.kt @@ -10,6 +10,7 @@ import android.widget.LinearLayout import androidx.core.graphics.ColorUtils import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -19,7 +20,7 @@ import com.mikepenz.fastadapter.listeners.ClickEventHook import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.databinding.ReaderChaptersSheetBinding import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.ui.reader.ReaderPresenter +import eu.kanade.tachiyomi.ui.reader.ReaderViewModel import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.isInNightMode @@ -28,6 +29,7 @@ import eu.kanade.tachiyomi.util.view.collapse import eu.kanade.tachiyomi.util.view.expand import eu.kanade.tachiyomi.util.view.isCollapsed import eu.kanade.tachiyomi.util.view.isExpanded +import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -37,7 +39,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr LinearLayout(context, attrs) { var sheetBehavior: BottomSheetBehavior? = null - lateinit var presenter: ReaderPresenter + lateinit var viewModel: ReaderViewModel var adapter: FastAdapter? = null private val itemAdapter = ItemAdapter() var selectedChapterId = -1L @@ -51,7 +53,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr } fun setup(activity: ReaderActivity) { - presenter = activity.presenter + viewModel = activity.viewModel val fullPrimary = activity.getResourceColor(R.attr.colorSurface) val primary = ColorUtils.setAlphaComponent(fullPrimary, 200) @@ -78,7 +80,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr binding.chapterRecycler.alpha = if (sheetBehavior.isExpanded()) 1f else 0f binding.chapterRecycler.isClickable = sheetBehavior.isExpanded() binding.chapterRecycler.isFocusable = sheetBehavior.isExpanded() - val canShowNav = presenter.getCurrentChapter()?.pages?.size ?: 1 > 1 + val canShowNav = viewModel.getCurrentChapter()?.pages?.size ?: 1 > 1 if (canShowNav) { activity.binding.readerNav.root.isVisible = sheetBehavior.isCollapsed() } @@ -87,6 +89,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr sheetBehavior?.addBottomSheetCallback( object : BottomSheetBehavior.BottomSheetCallback() { override fun onSlide(bottomSheet: View, progress: Float) { + binding.root.isVisible = true binding.pill.alpha = (1 - max(0f, progress)) * 0.25f val trueProgress = max(progress, 0f) activity.binding.readerNav.root.alpha = (1 - abs(progress)).coerceIn(0f, 1f) @@ -100,11 +103,11 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr } override fun onStateChanged(p0: View, state: Int) { - val canShowNav = (presenter.getCurrentChapter()?.pages?.size ?: 1) > 1 + val canShowNav = (viewModel.getCurrentChapter()?.pages?.size ?: 1) > 1 if (state == BottomSheetBehavior.STATE_COLLAPSED) { sheetBehavior?.isHideable = false (binding.chapterRecycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( - adapter?.getPosition(presenter.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0, + adapter?.getPosition(viewModel.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0, binding.chapterRecycler.height / 2 - 30.dpToPx, ) if (canShowNav) { @@ -130,6 +133,9 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr if (canShowNav) { activity.binding.readerNav.root.isInvisible = true } + binding.root.isInvisible = true + } else if (binding.root.isVisible) { + binding.root.isVisible = true } binding.chapterRecycler.isClickable = state == BottomSheetBehavior.STATE_EXPANDED binding.chapterRecycler.isFocusable = state == BottomSheetBehavior.STATE_EXPANDED @@ -144,16 +150,18 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr if (!sheetBehavior.isExpanded() || activity.isLoading) { false } else { - if (item.chapter.id != presenter.getCurrentChapter()?.chapter?.id) { + if (item.chapter.id != viewModel.getCurrentChapter()?.chapter?.id) { activity.binding.readerNav.leftChapter.isInvisible = true activity.binding.readerNav.rightChapter.isInvisible = true activity.isScrollingThroughPagesOrChapters = true - presenter.loadChapter(item.chapter) loadingPos = position val itemView = (binding.chapterRecycler.findViewHolderForAdapterPosition(position) as? ReaderChapterItem.ViewHolder)?.binding itemView?.bookmarkImage?.isVisible = false itemView?.progress?.isVisible = true + activity.lifecycleScope.launch { + activity.loadChapter(item.chapter) + } } true } @@ -175,7 +183,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr item: ReaderChapterItem, ) { if (!activity.isLoading && sheetBehavior.isExpanded()) { - presenter.toggleBookmark(item.chapter) + viewModel.toggleBookmark(item.chapter) refreshList() } } @@ -217,14 +225,14 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr fun refreshList() { launchUI { - val chapters = presenter.getChapters() + val chapters = viewModel.getChapters() selectedChapterId = chapters.find { it.isCurrent }?.chapter?.id ?: -1L itemAdapter.clear() itemAdapter.add(chapters) (binding.chapterRecycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( - adapter?.getPosition(presenter.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0, + adapter?.getPosition(viewModel.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0, binding.chapterRecycler.height / 2 - 30.dpToPx, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 4795b2c374..12f053699b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -1,66 +1,64 @@ package eu.kanade.tachiyomi.ui.reader.loader +import android.content.Context import com.github.junrar.exception.UnsupportedRarV5Exception import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter -import rx.Completable -import rx.Observable -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers +import eu.kanade.tachiyomi.util.system.withIOContext import timber.log.Timber /** * Loader used to retrieve the [PageLoader] for a given chapter. */ class ChapterLoader( + private val context: Context, private val downloadManager: DownloadManager, + private val downloadProvider: DownloadProvider, private val manga: Manga, private val source: Source, ) { /** - * Returns a completable that assigns the page loader and loads the its pages. It just - * completes if the chapter is already loaded. + * Assigns the chapter's page loader and loads the its pages. Returns immediately if the chapter + * is already loaded. */ - fun loadChapter(chapter: ReaderChapter): Completable { + suspend fun loadChapter(chapter: ReaderChapter) { if (chapterIsReady(chapter)) { - return Completable.complete() + return } - return Observable.just(chapter) - .doOnNext { chapter.state = ReaderChapter.State.Loading } - .observeOn(Schedulers.io()) - .flatMap { readerChapter -> - Timber.d("Loading pages for ${chapter.chapter.name}") - - val loader = getPageLoader(readerChapter) + chapter.state = ReaderChapter.State.Loading + withIOContext { + Timber.d("Loading pages for ${chapter.chapter.name}") + try { + val loader = getPageLoader(chapter) chapter.pageLoader = loader - loader.getPages().take(1).doOnNext { pages -> - pages.forEach { it.chapter = chapter } - } - } - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { pages -> - if (pages.isEmpty()) { - throw Exception(downloadManager.context.getString(R.string.no_pages_found)) - } + val pages = loader.getPages() + .onEach { it.chapter = chapter } - chapter.state = ReaderChapter.State.Loaded(pages) + if (pages.isEmpty()) { + throw Exception(context.getString(R.string.no_pages_found)) + } // If the chapter is partially read, set the starting page to the last the user read // otherwise use the requested page. if (!chapter.chapter.read) { chapter.requestedPage = chapter.chapter.last_page_read } + + chapter.state = ReaderChapter.State.Loaded(pages) + } catch (e: Throwable) { + chapter.state = ReaderChapter.State.Error(e) + throw e } - .toCompletable() - .doOnError { chapter.state = ReaderChapter.State.Error(it) } + } } /** @@ -74,9 +72,10 @@ class ChapterLoader( * Returns the page loader to use for this [chapter]. */ private fun getPageLoader(chapter: ReaderChapter): PageLoader { - val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga, true) + val dbChapter = chapter.chapter + val isDownloaded = downloadManager.isChapterDownloaded(dbChapter, manga, skipCache = true) return when { - isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager) + isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider) source is HttpSource -> HttpPageLoader(chapter, source) source is LocalSource -> source.getFormat(chapter.chapter).let { format -> when (format) { @@ -85,12 +84,12 @@ class ChapterLoader( is LocalSource.Format.Rar -> try { RarPageLoader(format.file) } catch (e: UnsupportedRarV5Exception) { - error(downloadManager.context.getString(R.string.loader_rar5_error)) + error(context.getString(R.string.loader_rar5_error)) } is LocalSource.Format.Epub -> EpubPageLoader(format.file) } } - else -> error(downloadManager.context.getString(R.string.source_not_installed)) + else -> error(context.getString(R.string.source_not_installed)) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt index 71671b6fc2..cff8e38a61 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt @@ -4,7 +4,6 @@ import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.system.ImageUtil -import rx.Observable import java.io.File import java.io.FileInputStream @@ -14,27 +13,23 @@ import java.io.FileInputStream class DirectoryPageLoader(val file: File) : PageLoader() { /** - * Returns an observable containing the pages found on this directory ordered with a natural - * comparator. + * Returns the pages found on this directory ordered with a natural comparator. */ - override fun getPages(): Observable> { + override suspend fun getPages(): List { return file.listFiles() - .filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } - .sortedWith(Comparator { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }) - .mapIndexed { i, file -> + ?.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } } + ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } + ?.mapIndexed { i, file -> val streamFn = { FileInputStream(file) } ReaderPage(i).apply { stream = streamFn - status = Page.READY + status = Page.State.READY } - } - .let { Observable.just(it) } + } ?: emptyList() } /** - * Returns an observable that emits a ready state. + * No additional action required to load the page */ - override fun getPage(page: ReaderPage): Observable { - return Observable.just(Page.READY) - } + override suspend fun loadPage(page: ReaderPage) {} } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index 6badeaa264..c7c06bb780 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader.loader import android.app.Application import android.net.Uri +import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadProvider @@ -9,12 +10,8 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder -import eu.kanade.tachiyomi.util.system.ImageUtil -import rx.Observable import uy.kohesive.injekt.injectLazy import java.io.File -import java.util.zip.ZipFile /** * Loader used to load a chapter from the downloaded chapters. @@ -24,69 +21,49 @@ class DownloadPageLoader( private val manga: Manga, private val source: Source, private val downloadManager: DownloadManager, + private val downloadProvider: DownloadProvider, ) : PageLoader() { - /** - * The application context. Needed to open input streams. - */ - private val context by injectLazy() - private val downloadProvider by lazy { DownloadProvider(context) } + // Needed to open input streams + private val context: Application by injectLazy() + + private var zipPageLoader: ZipPageLoader? = null + + override fun recycle() { + super.recycle() + zipPageLoader?.recycle() + } /** - * Returns an observable containing the pages found on this downloaded chapter. + * Returns the pages found on this downloaded chapter. */ - override fun getPages(): Observable> { - val chapterPath = downloadProvider.findChapterDir(chapter.chapter, manga, source) - if (chapterPath?.isFile == true) { - val zip = if (!File(chapterPath.filePath!!).canRead()) { - val tmpFile = File.createTempFile(chapterPath.name!!.replace(".cbz", ""), ".cbz") - val buffer = ByteArray(1024) - chapterPath.openInputStream().use { input -> - tmpFile.outputStream().use { fileOut -> - while (true) { - val length = input.read(buffer) - if (length <= 0) break - fileOut.write(buffer, 0, length) - } - fileOut.flush() - } - } - ZipFile(tmpFile.absolutePath) - } else { - ZipFile(chapterPath.filePath) - } - return zip.entries().toList() - .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } - .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - .mapIndexed { i, entry -> - val streamFn = { zip.getInputStream(entry) } - ReaderPage(i).apply { - stream = streamFn - status = Page.READY - } - } - .let { Observable.just(it) } + override suspend fun getPages(): List { + val dbChapter = chapter.chapter + val chapterPath = downloadProvider.findChapterDir(dbChapter, manga, source) + return if (chapterPath?.isFile == true) { + getPagesFromArchive(chapterPath) } else { - return downloadManager.buildPageList(source, manga, chapter.chapter) - .map { pages -> - pages.map { page -> - ReaderPage( - page.index, - page.url, - page.imageUrl, - - { - context.contentResolver.openInputStream(page.uri ?: Uri.EMPTY)!! - }, - ).apply { - status = Page.READY - } - } - } + getPagesFromDirectory() } } - override fun getPage(page: ReaderPage): Observable { - return Observable.just(Page.READY) // TODO maybe check if file still exists? + private suspend fun getPagesFromArchive(chapterPath: UniFile): List { + val loader = ZipPageLoader(File(chapterPath.filePath!!)).also { zipPageLoader = it } + return loader.getPages() + } + + private fun getPagesFromDirectory(): List { + val pages = downloadManager.buildPageList(source, manga, chapter.chapter) + return pages.map { page -> + ReaderPage(page.index, page.url, page.imageUrl, stream = { + context.contentResolver.openInputStream(page.uri ?: Uri.EMPTY)!! + },).apply { + status = Page.State.READY + } + } + } + + override suspend fun loadPage(page: ReaderPage) { + zipPageLoader?.loadPage(page) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt index 611d21882c..6d581f2ba0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt @@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.reader.loader import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.util.storage.EpubFile -import rx.Observable import java.io.File /** @@ -25,31 +24,23 @@ class EpubPageLoader(file: File) : PageLoader() { } /** - * Returns an observable containing the pages found on this zip archive ordered with a natural - * comparator. + * Returns the pages found on this zip archive ordered with a natural comparator. */ - override fun getPages(): Observable> { + override suspend fun getPages(): List { return epub.getImagesFromPages() .mapIndexed { i, path -> val streamFn = { epub.getInputStream(epub.getEntry(path)!!) } ReaderPage(i).apply { stream = streamFn - status = Page.READY + status = Page.State.READY } } - .let { Observable.just(it) } } /** - * Returns an observable that emits a ready state unless the loader was recycled. + * No additional action required to load the page */ - override fun getPage(page: ReaderPage): Observable { - return Observable.just( - if (isRecycled) { - Page.ERROR - } else { - Page.READY - }, - ) + override suspend fun loadPage(page: ReaderPage) { + check(!isRecycled) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt index 4bec781e79..aeb473ba41 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt @@ -1,28 +1,24 @@ package eu.kanade.tachiyomi.ui.reader.loader import eu.kanade.tachiyomi.data.cache.ChapterCache -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.util.lang.plusAssign +import eu.kanade.tachiyomi.util.system.awaitSingle +import eu.kanade.tachiyomi.util.system.launchIO +import eu.kanade.tachiyomi.util.system.withIOContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import rx.Completable -import rx.Observable -import rx.schedulers.Schedulers -import rx.subjects.PublishSubject -import rx.subjects.SerializedSubject -import rx.subscriptions.CompositeSubscription -import timber.log.Timber +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.suspendCancellableCoroutine import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import uy.kohesive.injekt.injectLazy import java.util.concurrent.PriorityBlockingQueue import java.util.concurrent.atomic.AtomicInteger import kotlin.math.min @@ -36,38 +32,27 @@ class HttpPageLoader( private val chapterCache: ChapterCache = Injekt.get(), ) : PageLoader() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** * A queue used to manage requests one by one while allowing priorities. */ private val queue = PriorityBlockingQueue() - private val subscriptions = CompositeSubscription() + private val preloadSize = 4 - private val preferences by injectLazy() - private var preloadSize = preferences.preloadSize().get() - - private val scope = CoroutineScope(Job() + Dispatchers.IO) init { - // Adding flow since we can reach reader settings after this is created - preferences.preloadSize().asFlow() - .onEach { - preloadSize = it + scope.launchIO { + flow { + while (true) { + emit(runInterruptible { queue.take() }.page) + } } - .launchIn(scope) - subscriptions += Observable.defer { Observable.just(queue.take().page) } - .filter { it.status == Page.QUEUE } - .concatMap { source.fetchImageFromCacheThenNet(it) } - .repeat() - .subscribeOn(Schedulers.io()) - .subscribe( - { - }, - { error -> - if (error !is InterruptedException) { - Timber.e(error) - } - }, - ) + .filter { it.status == Page.State.QUEUE } + .collect { + _loadPage(it) + } + } } /** @@ -76,77 +61,77 @@ class HttpPageLoader( override fun recycle() { super.recycle() scope.cancel() - subscriptions.unsubscribe() queue.clear() // Cache current page list progress for online chapters to allow a faster reopen val pages = chapter.pages if (pages != null) { - Completable - .fromAction { + launchIO { + try { // Convert to pages without reader information val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) } chapterCache.putPageListToCache(chapter.chapter, pagesToSave) + } catch (e: Throwable) { + if (e is CancellationException) { + throw e + } } - .onErrorComplete() - .subscribeOn(Schedulers.io()) - .subscribe() + } } } /** - * Returns an observable with the page list for a chapter. It tries to return the page list from - * the local cache, otherwise fallbacks to network. + * Returns the page list for a chapter. It tries to return the page list from the local cache, + * otherwise fallbacks to network. */ - override fun getPages(): Observable> { - return Observable.fromCallable { chapterCache.getPageListFromCache(chapter.chapter) } - .onErrorResumeNext { source.fetchPageList(chapter.chapter) } - .map { pages -> - pages.mapIndexed { index, page -> - // Don't trust sources and use our own indexing - ReaderPage(index, page.url, page.imageUrl) - } + override suspend fun getPages(): List { + val pages = try { + chapterCache.getPageListFromCache(chapter.chapter) + } catch (e: Throwable) { + if (e is CancellationException) { + throw e } + source.getPageList(chapter.chapter) + } + return pages.mapIndexed { index, page -> + // Don't trust sources and use our own indexing + ReaderPage(index, page.url, page.imageUrl) + } } /** - * Returns an observable that loads a page through the queue and listens to its result to - * emit new states. It handles re-enqueueing pages if they were evicted from the cache. + * Loads a page through the queue. Handles re-enqueueing pages if they were evicted from the cache. */ - override fun getPage(page: ReaderPage): Observable { - return Observable.defer { + override suspend fun loadPage(page: ReaderPage) { + withIOContext { val imageUrl = page.imageUrl // Check if the image has been deleted - if (page.status == Page.READY && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) { - page.status = Page.QUEUE + if (page.status == Page.State.READY && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) { + page.status = Page.State.QUEUE } // Automatically retry failed pages when subscribed to this page - if (page.status == Page.ERROR) { - page.status = Page.QUEUE + if (page.status == Page.State.ERROR) { + page.status = Page.State.QUEUE } - val statusSubject = SerializedSubject(PublishSubject.create()) - page.setStatusSubject(statusSubject) - val queuedPages = mutableListOf() - if (page.status == Page.QUEUE) { + if (page.status == Page.State.QUEUE) { queuedPages += PriorityPage(page, 1).also { queue.offer(it) } } queuedPages += preloadNextPages(page, preloadSize) - statusSubject.startWith(page.status) - .doOnUnsubscribe { + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { queuedPages.forEach { - if (it.page.status == Page.QUEUE) { + if (it.page.status == Page.State.QUEUE) { queue.remove(it) } } } + } } - .subscribeOn(Schedulers.io()) - .unsubscribeOn(Schedulers.io()) } /** @@ -161,7 +146,7 @@ class HttpPageLoader( return pages .subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size)) .mapNotNull { - if (it.status == Page.QUEUE) { + if (it.status == Page.State.QUEUE) { PriorityPage(it, 0).apply { queue.offer(this) } } else { null @@ -173,8 +158,8 @@ class HttpPageLoader( * Retries a page. This method is only called from user interaction on the viewer. */ override fun retryPage(page: ReaderPage) { - if (page.status == Page.ERROR) { - page.status = Page.QUEUE + if (page.status == Page.State.ERROR) { + page.status = Page.State.QUEUE } queue.offer(PriorityPage(page, 2)) } @@ -186,7 +171,6 @@ class HttpPageLoader( val page: ReaderPage, val priority: Int, ) : Comparable { - companion object { private val idGenerator = AtomicInteger() } @@ -200,61 +184,32 @@ class HttpPageLoader( } /** - * Returns an observable of the page with the downloaded image. + * Loads the page, retrieving the image URL and downloading the image if necessary. + * Downloaded images are stored in the chapter cache. * * @param page the page whose source image has to be downloaded. */ - private fun HttpSource.fetchImageFromCacheThenNet(page: ReaderPage): Observable { - return if (page.imageUrl.isNullOrEmpty()) { - getImageUrl(page).flatMap { getCachedImage(it) } - } else { - getCachedImage(page) + private suspend fun _loadPage(page: ReaderPage) { + try { + if (page.imageUrl.isNullOrEmpty()) { + page.status = Page.State.LOAD_PAGE + page.imageUrl = source.fetchImageUrl(page).awaitSingle() + } + val imageUrl = page.imageUrl!! + + if (!chapterCache.isImageInCache(imageUrl)) { + page.status = Page.State.DOWNLOAD_IMAGE + val imageResponse = source.getImage(page) + chapterCache.putImageToCache(imageUrl, imageResponse) + } + + page.stream = { chapterCache.getImageFile(imageUrl).inputStream() } + page.status = Page.State.READY + } catch (e: Throwable) { + page.status = Page.State.ERROR + if (e is CancellationException) { + throw e + } } } - - private fun HttpSource.getImageUrl(page: ReaderPage): Observable { - page.status = Page.LOAD_PAGE - return fetchImageUrl(page) - .doOnError { page.status = Page.ERROR } - .onErrorReturn { null } - .doOnNext { page.imageUrl = it } - .map { page } - } - - /** - * Returns an observable of the page that gets the image from the chapter or fallbacks to - * network and copies it to the cache calling [cacheImage]. - * - * @param page the page. - */ - private fun HttpSource.getCachedImage(page: ReaderPage): Observable { - val imageUrl = page.imageUrl ?: return Observable.just(page) - - return Observable.just(page) - .flatMap { - if (!chapterCache.isImageInCache(imageUrl)) { - cacheImage(page) - } else { - Observable.just(page) - } - } - .doOnNext { - page.stream = { chapterCache.getImageFile(imageUrl).inputStream() } - page.status = Page.READY - } - .doOnError { page.status = Page.ERROR } - .onErrorReturn { page } - } - - /** - * Returns an observable of the page that downloads the image to [ChapterCache]. - * - * @param page the page. - */ - private fun HttpSource.cacheImage(page: ReaderPage): Observable { - page.status = Page.DOWNLOAD_IMAGE - return fetchImage(page) - .doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) } - .map { page } - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt index de7e4e5410..720e81a43c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.reader.loader import androidx.annotation.CallSuper import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import rx.Observable /** * A loader used to load pages into the reader. Any open resources must be cleaned up when the @@ -26,16 +25,16 @@ abstract class PageLoader { } /** - * Returns an observable containing the list of pages of a chapter. Only the first emission - * will be used. + * Returns the list of pages of a chapter. */ - abstract fun getPages(): Observable> + abstract suspend fun getPages(): List /** - * Returns an observable that should inform of the progress of the page (see the Page class - * for the available states) + * Loads the page. May also preload other pages. + * Progress of the page loading should be followed via [page.statusFlow]. + * [loadPage] is not currently guaranteed to complete, so it should be launched asynchronously. */ - abstract fun getPage(page: ReaderPage): Observable + abstract suspend fun loadPage(page: ReaderPage) /** * Retries the given [page] in case it failed to load. This method only makes sense when an diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt index 5d94893179..58debe3b1b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt @@ -6,7 +6,6 @@ import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.system.ImageUtil -import rx.Observable import java.io.File import java.io.InputStream import java.io.PipedInputStream @@ -38,33 +37,27 @@ class RarPageLoader(file: File) : PageLoader() { } /** - * Returns an observable containing the pages found on this rar archive ordered with a natural + * Returns an RxJava Single containing the pages found on this rar archive ordered with a natural * comparator. */ - override fun getPages(): Observable> { + override suspend fun getPages(): List { return archive.fileHeaders.asSequence() .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } } .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .mapIndexed { i, header -> ReaderPage(i).apply { stream = { getStream(header) } - status = Page.READY + status = Page.State.READY } } - .let { Observable.just(it.toList()) } + .toList() } /** - * Returns an observable that emits a ready state unless the loader was recycled. + * No additional action required to load the page */ - override fun getPage(page: ReaderPage): Observable { - return Observable.just( - if (isRecycled) { - Page.ERROR - } else { - Page.READY - }, - ) + override suspend fun loadPage(page: ReaderPage) { + check(!isRecycled) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt index 3db97c8fa9..543241b304 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt @@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.system.ImageUtil -import rx.Observable import java.io.File import java.nio.charset.StandardCharsets import java.util.zip.ZipFile @@ -33,32 +32,25 @@ class ZipPageLoader(file: File) : PageLoader() { } /** - * Returns an observable containing the pages found on this zip archive ordered with a natural - * comparator. + * Returns the pages found on this zip archive ordered with a natural comparator. */ - override fun getPages(): Observable> { + override suspend fun getPages(): List { return zip.entries().asSequence() .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .mapIndexed { i, entry -> ReaderPage(i).apply { stream = { zip.getInputStream(entry) } - status = Page.READY + status = Page.State.READY } } - .let { Observable.just(it.toList()) } + .toList() } /** - * Returns an observable that emits a ready state unless the loader was recycled. + * No additional action required to load the page */ - override fun getPage(page: ReaderPage): Observable { - return Observable.just( - if (isRecycled) { - Page.ERROR - } else { - Page.READY - }, - ) + override suspend fun loadPage(page: ReaderPage) { + check(!isRecycled) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/InsertPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/InsertPage.kt index 970dec5238..254a7d6309 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/InsertPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/InsertPage.kt @@ -12,6 +12,6 @@ class InsertPage(parent: ReaderPage) : ReaderPage( fullPage = true firstHalf = false stream = parent.stream - status = READY + status = State.READY } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderGeneralView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderGeneralView.kt index 08946cda7d..c6b3338685 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderGeneralView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderGeneralView.kt @@ -15,9 +15,9 @@ class ReaderGeneralView @JvmOverloads constructor(context: Context, attrs: Attri override fun initGeneralPreferences() { binding.viewerSeries.onItemSelectedListener = { position -> val readingModeType = ReadingModeType.fromSpinner(position) - (context as ReaderActivity).presenter.setMangaReadingMode(readingModeType.flagValue) + (context as ReaderActivity).viewModel.setMangaReadingMode(readingModeType.flagValue) - val mangaViewer = activity.presenter.getMangaReadingMode() + val mangaViewer = activity.viewModel.getMangaReadingMode() if (mangaViewer == ReadingModeType.WEBTOON.flagValue || mangaViewer == ReadingModeType.CONTINUOUS_VERTICAL.flagValue) { initWebtoonPreferences() } else { @@ -25,16 +25,16 @@ class ReaderGeneralView @JvmOverloads constructor(context: Context, attrs: Attri } } binding.viewerSeries.setSelection( - (context as? ReaderActivity)?.presenter?.manga?.readingModeType?.let { + (context as? ReaderActivity)?.viewModel?.state?.value?.manga?.readingModeType?.let { ReadingModeType.fromPreference(it).prefValue } ?: 0, ) binding.rotationMode.onItemSelectedListener = { position -> val rotationType = OrientationType.fromSpinner(position) - (context as ReaderActivity).presenter.setMangaOrientationType(rotationType.flagValue) + (context as ReaderActivity).viewModel.setMangaOrientationType(rotationType.flagValue) } binding.rotationMode.setSelection( - (context as ReaderActivity).presenter.manga?.orientationType?.let { + (context as ReaderActivity).viewModel.manga?.orientationType?.let { OrientationType.fromPreference(it).prefValue } ?: 0, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderPagedView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderPagedView.kt index 4b4d105369..bc5c6496d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderPagedView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderPagedView.kt @@ -19,7 +19,7 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu override fun initGeneralPreferences() { with(binding) { scaleType.bindToPreference(preferences.imageScaleType(), 1) { - val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaReadingMode() ?: 0 + val mangaViewer = (context as? ReaderActivity)?.viewModel?.getMangaReadingMode() ?: 0 val isWebtoonView = ReadingModeType.isWebtoonType(mangaViewer) updatePagedGroup(!isWebtoonView) landscapeZoom.isVisible = it == SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE - 1 @@ -33,7 +33,7 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu pagerInvert.bindToPreference(preferences.pagerNavInverted()) extendPastCutout.bindToPreference(preferences.pagerCutoutBehavior()) pageLayout.bindToPreference(preferences.pageLayout()) { - val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaReadingMode() ?: 0 + val mangaViewer = (context as? ReaderActivity)?.viewModel?.getMangaReadingMode() ?: 0 val isWebtoonView = ReadingModeType.isWebtoonType(mangaViewer) updatePagedGroup(!isWebtoonView) } @@ -42,7 +42,7 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu pageLayout.title = pageLayout.title.toString().addBetaTag(context) - val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaReadingMode() ?: 0 + val mangaViewer = (context as? ReaderActivity)?.viewModel?.getMangaReadingMode() ?: 0 val isWebtoonView = ReadingModeType.isWebtoonType(mangaViewer) val hasMargins = mangaViewer == ReadingModeType.CONTINUOUS_VERTICAL.flagValue cropBordersWebtoon.bindToPreference(if (hasMargins) preferences.cropBorders() else preferences.cropBordersWebtoon()) @@ -61,7 +61,7 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu } fun updatePrefs() { - val mangaViewer = activity.presenter.getMangaReadingMode() + val mangaViewer = activity.viewModel.getMangaReadingMode() val isWebtoonView = ReadingModeType.isWebtoonType(mangaViewer) val hasMargins = mangaViewer == ReadingModeType.CONTINUOUS_VERTICAL.flagValue binding.cropBordersWebtoon.bindToPreference(if (hasMargins) preferences.cropBorders() else preferences.cropBordersWebtoon()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/TabbedReaderSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/TabbedReaderSettingsSheet.kt index 61b402c406..9862d24f01 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/TabbedReaderSettingsSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/TabbedReaderSettingsSheet.kt @@ -38,7 +38,7 @@ class TabbedReaderSettingsSheet( ) as ReaderFilterView var showWebtoonView: Boolean = run { - val mangaViewer = readerActivity.presenter.getMangaReadingMode() + val mangaViewer = readerActivity.viewModel.getMangaReadingMode() ReadingModeType.isWebtoonType(mangaViewer) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt index 9690f25f12..0cbc5f2c0e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/Pager.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager import android.content.Context +import android.os.Parcelable import android.view.HapticFeedbackConstants import android.view.KeyEvent import android.view.MotionEvent @@ -27,6 +28,17 @@ open class Pager( */ var longTapListener: ((MotionEvent) -> Boolean)? = null + var isRestoring = false + + override fun onRestoreInstanceState(state: Parcelable?) { + isRestoring = true + val currentItem = currentItem + super.onRestoreInstanceState(state) + setCurrentItem(currentItem, false) + isRestoring = false +// super.onRestoreInstanceState(state) + } + /** * Gesture listener that implements tap and long tap events. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt index 362549a6ff..81f0f2ff3b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt @@ -38,11 +38,13 @@ import eu.kanade.tachiyomi.util.system.topCutoutInset import eu.kanade.tachiyomi.util.view.backgroundColor import eu.kanade.tachiyomi.util.view.isVisibleOnScreen import eu.kanade.tachiyomi.widget.ViewPagerAdapter -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Default import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import rx.Observable import rx.Subscription @@ -51,7 +53,6 @@ import rx.schedulers.Schedulers import timber.log.Timber import uy.kohesive.injekt.injectLazy import java.io.InputStream -import java.util.concurrent.TimeUnit import kotlin.math.min import kotlin.math.roundToInt @@ -87,37 +88,47 @@ class PagerPageHolder( private var decodeErrorLayout: ViewGroup? = null /** - * Subscription for status changes of the page. + * Job for loading the page. */ - private var statusSubscription: Subscription? = null + private var loadJob: Job? = null /** - * Subscription for progress changes of the page. + * Job for status changes of the page. */ - private var progressSubscription: Subscription? = null + private var statusJob: Job? = null /** - * Subscription for status changes of the page. + * Job for progress changes of the page. */ - private var extraStatusSubscription: Subscription? = null + private var progressJob: Job? = null /** - * Subscription for progress changes of the page. + * Job for loading the page. */ - private var extraProgressSubscription: Subscription? = null + private var extraLoadJob: Job? = null + + /** + * Job for status changes of the page. + */ + private var extraStatusJob: Job? = null + + /** + * Job for progress changes of the page. + */ + private var extraProgressJob: Job? = null /** * Subscription used to read the header of the image. This is needed in order to instantiate - * the appropriate image view depending if the image is animated (GIF). + * the appropiate image view depending if the image is animated (GIF). */ private var readImageHeaderSubscription: Subscription? = null - private var status: Int = 0 - private var extraStatus: Int = 0 + private var status = Page.State.READY + private var extraStatus = Page.State.READY private var progress: Int = 0 private var extraProgress: Int = 0 - private var scope: CoroutineScope? = null + private var scope = MainScope() init { addView(progressBar) @@ -126,8 +137,7 @@ class PagerPageHolder( marginStart = ((context.resources.displayMetrics.widthPixels) / 2 + viewer.config.hingeGapSize) / 2 } } - scope = CoroutineScope(Job() + Default) - observeStatus() + launchLoadJob() setBackgroundColor( when (val theme = viewer.config.readerTheme) { 3 -> Color.TRANSPARENT @@ -185,16 +195,64 @@ class PagerPageHolder( @SuppressLint("ClickableViewAccessibility") override fun onDetachedFromWindow() { super.onDetachedFromWindow() - unsubscribeProgress(1) - unsubscribeStatus(1) - unsubscribeProgress(2) - unsubscribeStatus(2) + cancelProgressJob(1) + cancelLoadJob(1) + cancelProgressJob(2) + cancelLoadJob(2) unsubscribeReadImageHeader() - scope?.cancel() - scope = null (pageView as? SubsamplingScaleImageView)?.setOnImageEventListener(null) } + /** + * Starts loading the page and processing changes to the page's status. + * + * @see processStatus + */ + private fun launchLoadJob() { + loadJob?.cancel() + statusJob?.cancel() + + val loader = page.chapter.pageLoader ?: return + loadJob = scope.launch { + loader.loadPage(page) + } + statusJob = scope.launch { + page.statusFlow.collectLatest { processStatus(it) } + } + val extraPage = extraPage ?: return + extraLoadJob = scope.launch { + loader.loadPage(extraPage) + } + extraStatusJob = scope.launch { + extraPage.statusFlow.collectLatest { processStatus2(it) } + } + } + + private fun launchProgressJob() { + progressJob?.cancel() + progressJob = scope.launch { + page.progressFlow.collectLatest { value -> + progress = value + if (extraPage == null) { + progressBar.setProgress(progress) + } else { + progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt()) + } + } + } + } + + private fun launchProgressJob2() { + val extraPage = extraPage ?: return + extraProgressJob?.cancel() + extraProgressJob = scope.launch { + extraPage.progressFlow.collectLatest { value -> + extraProgress = value + progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt()) + } + } + } + fun onPageSelected(forward: Boolean?) { (pageView as? SubsamplingScaleImageView)?.apply { if (isReady) { @@ -304,88 +362,28 @@ class PagerPageHolder( } } - /** - * Observes the status of the page and notify the changes. - * - * @see processStatus - */ - private fun observeStatus() { - statusSubscription?.unsubscribe() - - val loader = page.chapter.pageLoader ?: return - statusSubscription = loader.getPage(page) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - status = it - processStatus(it) - } - val extraPage = extraPage ?: return - val loader2 = extraPage.chapter.pageLoader ?: return - extraStatusSubscription = loader2.getPage(extraPage) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - extraStatus = it - processStatus2(it) - } - } - - /** - * Observes the progress of the page and updates view. - */ - private fun observeProgress() { - progressSubscription?.unsubscribe() - - progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS) - .map { page.progress } - .distinctUntilChanged() - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { value -> - progress = value - if (extraPage == null) { - progressBar.setProgress(progress) - } else { - progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt()) - } - } - } - - private fun observeProgress2() { - extraProgressSubscription?.unsubscribe() - val extraPage = extraPage ?: return - extraProgressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS) - .map { extraPage.progress } - .distinctUntilChanged() - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { value -> - extraProgress = value - progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt()) - } - } - /** * Called when the status of the page changes. * * @param status the new status of the page. */ - private fun processStatus(status: Int) { + private fun processStatus(status: Page.State) { when (status) { - Page.QUEUE -> setQueued() - Page.LOAD_PAGE -> setLoading() - Page.DOWNLOAD_IMAGE -> { - observeProgress() + Page.State.QUEUE -> setQueued() + Page.State.LOAD_PAGE -> setLoading() + Page.State.DOWNLOAD_IMAGE -> { + launchProgressJob() setDownloading() } - Page.READY -> { - if (extraStatus == Page.READY || extraPage == null) { + Page.State.READY -> { + if (extraStatus == Page.State.READY || extraPage == null) { setImage() } - unsubscribeProgress(1) + cancelProgressJob(1) } - Page.ERROR -> { + Page.State.ERROR -> { setError() - unsubscribeProgress(1) + cancelProgressJob(1) } } } @@ -395,43 +393,51 @@ class PagerPageHolder( * * @param status the new status of the page. */ - private fun processStatus2(status: Int) { + private fun processStatus2(status: Page.State) { when (status) { - Page.QUEUE -> setQueued() - Page.LOAD_PAGE -> setLoading() - Page.DOWNLOAD_IMAGE -> { - observeProgress2() + Page.State.QUEUE -> setQueued() + Page.State.LOAD_PAGE -> setLoading() + Page.State.DOWNLOAD_IMAGE -> { + launchProgressJob2() setDownloading() } - Page.READY -> { - if (this.status == Page.READY) { + Page.State.READY -> { + if (this.status == Page.State.READY) { setImage() } - unsubscribeProgress(2) + cancelProgressJob(2) } - Page.ERROR -> { + Page.State.ERROR -> { setError() - unsubscribeProgress(2) + cancelProgressJob(2) } } } /** - * Unsubscribes from the status subscription. + * Cancels loading the page and processing changes to the page's status. */ - private fun unsubscribeStatus(page: Int) { - val subscription = if (page == 1) statusSubscription else extraStatusSubscription - subscription?.unsubscribe() - if (page == 1) statusSubscription = null else extraStatusSubscription = null + private fun cancelLoadJob(page: Int) { + if (page == 1) { + loadJob?.cancel() + loadJob = null + statusJob?.cancel() + statusJob = null + } else { + extraLoadJob?.cancel() + extraLoadJob = null + extraStatusJob?.cancel() + extraStatusJob = null + } } - /** - * Unsubscribes from the progress subscription. - */ - private fun unsubscribeProgress(page: Int) { - val subscription = if (page == 1) progressSubscription else extraProgressSubscription - subscription?.unsubscribe() - if (page == 1) progressSubscription = null else extraProgressSubscription = null + private fun cancelProgressJob(page: Int) { + (if (page == 1) progressJob else extraProgressJob)?.cancel() + if (page == 1) { + progressJob = null + } else { + extraProgressJob = null + } } /** @@ -515,7 +521,7 @@ class PagerPageHolder( setImage(bytesStream, false, imageConfig) bytesStream.close() - scope?.launchUI { + scope.launchUI { try { pageView?.background = setBG(bytesArray) } catch (e: Exception) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt index f78a91df03..71b162d7e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt @@ -59,7 +59,7 @@ class PagerTransitionHolder( addView(transitionView) addView(pagesContainer) - transitionView.bind(transition, viewer.downloadManager, viewer.activity.presenter.manga) + transitionView.bind(transition, viewer.downloadManager, viewer.activity.viewModel.state.value.manga) transition.to?.let { observeStatus(it) } if (viewer.config.hingeGapSize > 0) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt index a25f3a549a..ab93a845ec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt @@ -89,6 +89,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { private var pagerListener = object : ViewPager.SimpleOnPageChangeListener() { override fun onPageSelected(position: Int) { + if (pager.isRestoring) return val page = adapter.joinedItems.getOrNull(position) if (!activity.isScrollingThroughPagesOrChapters && page?.first !is ChapterTransition) { activity.hideMenu() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index 471c422107..56f570f44f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -23,13 +23,16 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.dpToPx +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import java.io.BufferedInputStream import java.io.InputStream -import java.util.concurrent.TimeUnit /** * Holder of the webtoon reader for a single page of a chapter. @@ -75,19 +78,26 @@ class WebtoonPageHolder( */ private var page: ReaderPage? = null - /** - * Subscription for status changes of the page. - */ - private var statusSubscription: Subscription? = null + private val scope = MainScope() /** - * Subscription for progress changes of the page. + * Job for loading the page. */ - private var progressSubscription: Subscription? = null + private var loadJob: Job? = null + + /** + * Job for status changes of the page. + */ + private var statusJob: Job? = null + + /** + * Job for progress changes of the page. + */ + private var progressJob: Job? = null /** * Subscription used to read the header of the image. This is needed in order to instantiate - * the appropiate image view depending if the image is animated (GIF). + * the appropriate image view depending if the image is animated (GIF). */ private var readImageHeaderSubscription: Subscription? = null @@ -105,7 +115,7 @@ class WebtoonPageHolder( */ fun bind(page: ReaderPage) { this.page = page - observeStatus() + launchLoadJob() refreshLayoutParams() } @@ -124,8 +134,8 @@ class WebtoonPageHolder( * Called when the view is recycled and added to the view pool. */ override fun recycle() { - unsubscribeStatus() - unsubscribeProgress() + cancelLoadJob() + cancelProgressJob() unsubscribeReadImageHeader() removeDecodeErrorLayout() @@ -138,34 +148,30 @@ class WebtoonPageHolder( * * @see processStatus */ - private fun observeStatus() { - unsubscribeStatus() + private fun launchLoadJob() { + cancelLoadJob() val page = page ?: return val loader = page.chapter.pageLoader ?: return - statusSubscription = loader.getPage(page) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { processStatus(it) } - - addSubscription(statusSubscription) + loadJob = scope.launch { + loader.loadPage(page) + } + statusJob = scope.launch { + page.statusFlow.collectLatest { processStatus(it) } + } } /** * Observes the progress of the page and updates view. */ - private fun observeProgress() { - unsubscribeProgress() + private fun launchProgressJob() { + cancelProgressJob() val page = page ?: return - progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS) - .map { page.progress } - .distinctUntilChanged() - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { value -> progressBar.setProgress(value) } - - addSubscription(progressSubscription) + progressJob = scope.launch { + page.progressFlow.collectLatest { value -> progressBar.setProgress(value) } + } } /** @@ -173,39 +179,41 @@ class WebtoonPageHolder( * * @param status the new status of the page. */ - private fun processStatus(status: Int) { + private fun processStatus(status: Page.State) { when (status) { - Page.QUEUE -> setQueued() - Page.LOAD_PAGE -> setLoading() - Page.DOWNLOAD_IMAGE -> { - observeProgress() + Page.State.QUEUE -> setQueued() + Page.State.LOAD_PAGE -> setLoading() + Page.State.DOWNLOAD_IMAGE -> { + launchProgressJob() setDownloading() } - Page.READY -> { + Page.State.READY -> { setImage() - unsubscribeProgress() + cancelProgressJob() } - Page.ERROR -> { + Page.State.ERROR -> { setError() - unsubscribeProgress() + cancelProgressJob() } } } /** - * Unsubscribes from the status subscription. + * Cancels loading the page and processing changes to the page's status. */ - private fun unsubscribeStatus() { - removeSubscription(statusSubscription) - statusSubscription = null + private fun cancelLoadJob() { + loadJob?.cancel() + loadJob = null + statusJob?.cancel() + statusJob = null } /** * Unsubscribes from the progress subscription. */ - private fun unsubscribeProgress() { - removeSubscription(progressSubscription) - progressSubscription = null + private fun cancelProgressJob() { + progressJob?.cancel() + progressJob = null } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt index 20d829e2d6..5131552f4c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt @@ -64,7 +64,7 @@ class WebtoonTransitionHolder( * Binds the given [transition] with this view holder, subscribing to its state. */ fun bind(transition: ChapterTransition) { - transitionView.bind(transition, viewer.downloadManager, viewer.activity.presenter.manga) + transitionView.bind(transition, viewer.downloadManager, viewer.activity.viewModel.state.value.manga) transition.to?.let { observeStatus(it, transition) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt index 9a8b24be8e..517ba818f7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt @@ -139,7 +139,7 @@ class EpubFile(file: File) : Closeable { */ private fun getPagesFromDocument(document: Document): List { val pages = document.select("manifest > item") - .filter { "application/xhtml+xml" == it.attr("media-type") } + .filter { node -> "application/xhtml+xml" == node.attr("media-type") } .associateBy { it.attr("id") } val spine = document.select("spine > itemref").map { it.attr("idref") } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt index 08f335668f..5750f72bcc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -23,6 +24,9 @@ fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job = launch(Dispatchers.Main, block = block) +fun CoroutineScope.launchNonCancellable(block: suspend CoroutineScope.() -> Unit): Job = + launchIO { withContext(NonCancellable, block) } + suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block)