From 2ab6af6471766c537a8a04f9221a652f0ee3a781 Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 26 Oct 2020 10:52:28 -0400 Subject: [PATCH] Consume and extend 1.x Source API TODO: make the rest of the app actually call the 1.x functions --- app/build.gradle | 6 + .../tachiyomi/data/database/models/Manga.kt | 14 ++ .../tachiyomi/source/CatalogueSource.kt | 2 +- .../java/eu/kanade/tachiyomi/source/Source.kt | 44 +++- .../eu/kanade/tachiyomi/source/model/Page.kt | 7 + .../kanade/tachiyomi/source/model/SChapter.kt | 22 ++ .../kanade/tachiyomi/source/model/SManga.kt | 28 +++ .../tachiyomi/util/lang/RxCoroutineBridge.kt | 230 ++++++++++++++++++ 8 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt diff --git a/app/build.gradle b/app/build.gradle index 47ffb2c1e..5543c2cf4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -129,6 +129,9 @@ androidExtensions { dependencies { + // Source models and interfaces from Tachiyomi 1.x + implementation 'tachiyomi.sourceapi:source-api:1.1' + // AndroidX libraries implementation 'androidx.annotation:annotation:1.2.0-alpha01' implementation 'androidx.appcompat:appcompat:1.3.0-alpha02' @@ -297,6 +300,9 @@ buildscript { repositories { mavenCentral() + maven { + url "https://dl.bintray.com/tachiyomiorg/maven" + } } // See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt index 01b51935e..164ecc101 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.data.database.models import eu.kanade.tachiyomi.source.model.SManga +import tachiyomi.source.model.MangaInfo interface Manga : SManga { @@ -98,3 +99,16 @@ interface Manga : SManga { } } } + +fun Manga.toMangaInfo(): MangaInfo { + return MangaInfo( + artist = this.artist ?: "", + author = this.author ?: "", + cover = this.thumbnail_url ?: "", + description = this.description ?: "", + genres = this.getGenres() ?: emptyList(), + key = this.url, + status = this.status, + title = this.title + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt index c78033ea6..f9e416def 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt @@ -9,7 +9,7 @@ interface CatalogueSource : Source { /** * An ISO 639-1 compliant language code (two letters in lower case). */ - val lang: String + override val lang: String /** * Whether the source has support for latest updates. diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt index fe7318ec2..c7896e88a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt @@ -5,30 +5,42 @@ import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.toChapterInfo +import eu.kanade.tachiyomi.source.model.toMangaInfo +import eu.kanade.tachiyomi.source.model.toPageInfo +import eu.kanade.tachiyomi.source.model.toSChapter +import eu.kanade.tachiyomi.source.model.toSManga +import eu.kanade.tachiyomi.util.lang.awaitSingle import rx.Observable +import tachiyomi.source.model.ChapterInfo +import tachiyomi.source.model.MangaInfo import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get /** * A basic interface for creating a source. It could be an online source, a local source, etc... */ -interface Source { +interface Source : tachiyomi.source.Source { /** * Id for the source. Must be unique. */ - val id: Long + override val id: Long /** * Name of the source. */ - val name: String + override val name: String + + override val lang: String + get() = "" /** * Returns an observable with the updated details for a manga. * * @param manga the manga to update. */ + @Deprecated("Use getMangaDetails instead") fun fetchMangaDetails(manga: SManga): Observable /** @@ -36,6 +48,7 @@ interface Source { * * @param manga the manga to update. */ + @Deprecated("Use getChapterList instead") fun fetchChapterList(manga: SManga): Observable> /** @@ -43,7 +56,32 @@ interface Source { * * @param chapter the chapter. */ + @Deprecated("Use getPageList instead") fun fetchPageList(chapter: SChapter): Observable> + + /** + * [1.x API] Get the updated details for a manga. + */ + override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo { + return fetchMangaDetails(manga.toSManga()).awaitSingle() + .toMangaInfo() + } + + /** + * [1.x API] Get all the available chapters for a manga. + */ + override suspend fun getChapterList(manga: MangaInfo): List { + return fetchChapterList(manga.toSManga()).awaitSingle() + .map { it.toChapterInfo() } + } + + /** + * [1.x API] Get the list of pages a chapter has. + */ + override suspend fun getPageList(chapter: ChapterInfo): List { + return fetchPageList(chapter.toSChapter()).awaitSingle() + .map { it.toPageInfo() } + } } fun Source.icon(): Drawable? = Injekt.get().getAppIconForSource(this) 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 22436bfe8..c57388a91 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 @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.source.model import android.net.Uri import eu.kanade.tachiyomi.network.ProgressListener import rx.subjects.Subject +import tachiyomi.source.model.PageUrl open class Page( val index: Int, @@ -61,3 +62,9 @@ open class Page( const val ERROR = 4 } } + +fun Page.toPageInfo(): PageUrl { + return PageUrl( + url = this.imageUrl ?: this.url + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt index f53bbe8f0..e9d10fb89 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.source.model +import tachiyomi.source.model.ChapterInfo import java.io.Serializable interface SChapter : Serializable { @@ -28,3 +29,24 @@ interface SChapter : Serializable { } } } + +fun SChapter.toChapterInfo(): ChapterInfo { + return ChapterInfo( + dateUpload = this.date_upload, + key = this.url, + name = this.name, + number = this.chapter_number, + scanlator = this.scanlator ?: "" + ) +} + +fun ChapterInfo.toSChapter(): SChapter { + val chapter = this + return SChapter.create().apply { + url = chapter.key + name = chapter.name + date_upload = chapter.dateUpload + chapter_number = chapter.number + scanlator = chapter.scanlator + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt index 63911e10b..5924aca86 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.source.model +import tachiyomi.source.model.MangaInfo import java.io.Serializable interface SManga : Serializable { @@ -61,3 +62,30 @@ interface SManga : Serializable { } } } + +fun SManga.toMangaInfo(): MangaInfo { + return MangaInfo( + key = this.url, + title = this.title, + artist = this.artist ?: "", + author = this.author ?: "", + description = this.description ?: "", + genres = this.genre?.split(", ") ?: emptyList(), + status = this.status, + cover = this.thumbnail_url ?: "" + ) +} + +fun MangaInfo.toSManga(): SManga { + val mangaInfo = this + return SManga.create().apply { + url = mangaInfo.key + title = mangaInfo.title + artist = mangaInfo.artist + author = mangaInfo.author + description = mangaInfo.description + genre = mangaInfo.genres.joinToString(", ") + status = mangaInfo.status + thumbnail_url = mangaInfo.cover + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt new file mode 100644 index 000000000..6e1be8681 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt @@ -0,0 +1,230 @@ +package eu.kanade.tachiyomi.util.lang + +import com.pushtorefresh.storio.operations.PreparedOperation +import com.pushtorefresh.storio.sqlite.operations.get.PreparedGetObject +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import rx.Completable +import rx.CompletableSubscriber +import rx.Emitter +import rx.Observable +import rx.Observer +import rx.Scheduler +import rx.Single +import rx.SingleSubscriber +import rx.Subscriber +import rx.Subscription +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/* + * Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY. + */ + +@ExperimentalCoroutinesApi +suspend fun Single.await(subscribeOn: Scheduler? = null): T { + return suspendCancellableCoroutine { continuation -> + val self = if (subscribeOn != null) subscribeOn(subscribeOn) else this + lateinit var sub: Subscription + sub = self.subscribe( + { + continuation.resume(it) { + sub.unsubscribe() + } + }, + { + if (!continuation.isCancelled) { + continuation.resumeWithException(it) + } + } + ) + + continuation.invokeOnCancellation { + sub.unsubscribe() + } + } +} + +suspend fun PreparedOperation.await(): T = asRxSingle().await() +suspend fun PreparedGetObject.await(): T? = asRxSingle().await() + +@ExperimentalCoroutinesApi +suspend fun Completable.awaitSuspending(subscribeOn: Scheduler? = null) { + return suspendCancellableCoroutine { continuation -> + val self = if (subscribeOn != null) subscribeOn(subscribeOn) else this + lateinit var sub: Subscription + sub = self.subscribe( + { + continuation.resume(Unit) { + sub.unsubscribe() + } + }, + { + if (!continuation.isCancelled) { + continuation.resumeWithException(it) + } + } + ) + + continuation.invokeOnCancellation { + sub.unsubscribe() + } + } +} + +suspend fun Completable.awaitCompleted(): Unit = suspendCancellableCoroutine { cont -> + subscribe( + object : CompletableSubscriber { + override fun onSubscribe(s: Subscription) { + cont.unsubscribeOnCancellation(s) + } + + override fun onCompleted() { + cont.resume(Unit) + } + + override fun onError(e: Throwable) { + cont.resumeWithException(e) + } + } + ) +} + +suspend fun Single.await(): T = suspendCancellableCoroutine { cont -> + cont.unsubscribeOnCancellation( + subscribe( + object : SingleSubscriber() { + override fun onSuccess(t: T) { + cont.resume(t) + } + + override fun onError(error: Throwable) { + cont.resumeWithException(error) + } + } + ) + ) +} + +@OptIn(InternalCoroutinesApi::class, ExperimentalCoroutinesApi::class) +suspend fun Observable.awaitFirst(): T = first().awaitOne() + +@OptIn(InternalCoroutinesApi::class, ExperimentalCoroutinesApi::class) +suspend fun Observable.awaitFirstOrDefault(default: T): T = firstOrDefault(default).awaitOne() + +@OptIn(InternalCoroutinesApi::class, ExperimentalCoroutinesApi::class) +suspend fun Observable.awaitFirstOrNull(): T? = firstOrDefault(null).awaitOne() + +@OptIn(InternalCoroutinesApi::class, ExperimentalCoroutinesApi::class) +suspend fun Observable.awaitFirstOrElse(defaultValue: () -> T): T = switchIfEmpty( + Observable.fromCallable( + defaultValue + ) +).first().awaitOne() + +@OptIn(InternalCoroutinesApi::class, ExperimentalCoroutinesApi::class) +suspend fun Observable.awaitLast(): T = last().awaitOne() + +@OptIn(InternalCoroutinesApi::class, ExperimentalCoroutinesApi::class) +suspend fun Observable.awaitSingle(): T = single().awaitOne() + +suspend fun Observable.awaitSingleOrDefault(default: T): T = singleOrDefault(default).awaitOne() + +suspend fun Observable.awaitSingleOrNull(): T? = singleOrDefault(null).awaitOne() + +@OptIn(InternalCoroutinesApi::class, ExperimentalCoroutinesApi::class) +private suspend fun Observable.awaitOne(): T = suspendCancellableCoroutine { cont -> + cont.unsubscribeOnCancellation( + subscribe( + object : Subscriber() { + override fun onStart() { + request(1) + } + + override fun onNext(t: T) { + cont.resume(t) + } + + override fun onCompleted() { + if (cont.isActive) cont.resumeWithException( + IllegalStateException( + "Should have invoked onNext" + ) + ) + } + + override fun onError(e: Throwable) { + /* + * Rx1 observable throws NoSuchElementException if cancellation happened before + * element emission. To mitigate this we try to atomically resume continuation with exception: + * if resume failed, then we know that continuation successfully cancelled itself + */ + val token = cont.tryResumeWithException(e) + if (token != null) { + cont.completeResume(token) + } + } + } + ) + ) +} + +internal fun CancellableContinuation.unsubscribeOnCancellation(sub: Subscription) = + invokeOnCancellation { sub.unsubscribe() } + +@ExperimentalCoroutinesApi +fun Observable.asFlow(): Flow = callbackFlow { + val observer = object : Observer { + override fun onNext(t: T) { + offer(t) + } + + override fun onError(e: Throwable) { + close(e) + } + + override fun onCompleted() { + close() + } + } + val subscription = subscribe(observer) + awaitClose { subscription.unsubscribe() } +} + +@ExperimentalCoroutinesApi +fun Flow.asObservable(backpressureMode: Emitter.BackpressureMode = Emitter.BackpressureMode.NONE): Observable { + return Observable.create( + { emitter -> + /* + * ATOMIC is used here to provide stable behaviour of subscribe+dispose pair even if + * asObservable is already invoked from unconfined + */ + val job = GlobalScope.launch(Dispatchers.Unconfined, start = CoroutineStart.ATOMIC) { + try { + collect { emitter.onNext(it) } + emitter.onCompleted() + } catch (e: Throwable) { + // Ignore `CancellationException` as error, since it indicates "normal cancellation" + if (e !is CancellationException) { + emitter.onError(e) + } else { + emitter.onCompleted() + } + } + } + emitter.setCancellation { job.cancel() } + }, + backpressureMode + ) +}