Move to Ktor, Part 1 (#415)

* Ktor 3.0.0-beta-1

* Drop chunker since we no more se okhttp

* Move getGalleryDetail to ktor

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

* Migration

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
This commit is contained in:
Vladimir DostoyevskiyandGG Bond authored and GitHub committed 2023-12-02 09:33:46 +08:00
1 parent ff8d8916aa
commit 52f314f6b0
9 files changed
+225 -216

No files matched your search

-3
View File
@@ -269,9 +269,6 @@ dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.jsoup)
debugImplementation(libs.chucker)
releaseImplementation(libs.chucker.nop)
coreLibraryDesugaring(libs.desugar)
"gmsImplementation"(libs.bundles.cronet)
+3
View File
@@ -16,5 +16,8 @@
setWindowFocused(boolean);
}
# Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder
-allowaccessmodification
-repackageclasses
@@ -35,7 +35,6 @@ import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.ktbuilder.cache
import com.hippo.ehviewer.ktbuilder.chunker
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.ktbuilder.httpClient
import com.hippo.ehviewer.ktbuilder.imageLoader
@@ -57,6 +56,8 @@ import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import kotlinx.coroutines.launch
import moe.tarsin.kt.unreachable
import okhttp3.AsyncDns
@@ -178,13 +179,20 @@ class EhApplication : Application(), ImageLoaderFactory {
}
companion object {
val ktorClient by lazy {
HttpClient(OkHttp) {
engine {
preconfigured = baseOkHttpClient
}
}
}
val baseOkHttpClient by lazy {
httpClient {
cookieJar(EhCookieStore)
if (isAtLeastQ) {
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
}
chunker { alwaysReadResponseBody(false) }
addInterceptor(UncaughtExceptionInterceptor())
addInterceptor(CloudflareInterceptor(appCtx))
}
@@ -16,6 +16,9 @@
package com.hippo.ehviewer.client
import android.util.Log
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parZip
import com.hippo.ehviewer.R
@@ -54,6 +57,11 @@ import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.isCronetSupported
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.statement.HttpStatement
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import java.io.File
@@ -70,23 +78,26 @@ import moe.tarsin.coroutines.runSuspendCatching
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import org.jsoup.Jsoup
import splitties.init.appCtx
private const val TAG = "EhEngine"
const val TAG = "EhEngine"
private const val MAX_REQUEST_SIZE = 25
private const val U_CONFIG_TEXT = "Selected Profile"
private val MEDIA_TYPE_JPEG: MediaType = "image/jpeg".toMediaType()
private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
// Don't translate coroutine cancellation
if (e is CancellationException) throw e
// Check sad panda(without panda)
if (body.isEmpty()) {
val empty = body.fold(
{ it.isEmpty() },
{ !it.hasRemaining() },
)
if (empty) {
if (EhUtils.isExHentai) {
throw EhException("Sad Panda\n(without panda)")
} else {
@@ -95,10 +106,12 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
}
// Check Gallery Not Available
if (body.contains("Gallery Not Available - ")) {
val error = GalleryNotAvailableParser.parse(body)
if (!error.isNullOrBlank()) {
throw EhException(error)
body.onLeft {
if ("Gallery Not Available - " in it) {
val error = GalleryNotAvailableParser.parse(it)
if (!error.isNullOrBlank()) {
throw EhException(error)
}
}
}
@@ -108,12 +121,17 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
}
if (e is ParseException || e is SerializationException) {
if ("<" !in body) {
throw EhException(body)
} else {
if (Settings.saveParseErrorBody) AppConfig.saveParseErrorBody(e, body)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
body.onLeft {
if ("<" !in it) {
throw EhException(it)
} else {
if (Settings.saveParseErrorBody) AppConfig.saveParseErrorBody(e, it)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
}
}
body.onRight {
}
// Save content for ByteBuffer as well
}
// We can't translate it, rethrow it anyway
@@ -172,16 +190,26 @@ suspend fun <T> fetchCompat(
}
}
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
private suspend inline fun <T> Request.executeAndParsingWith(block: suspend String.() -> T): T {
Log.d(TAG, url.toString())
return execute {
val body = body.string()
try {
block(body)
} catch (e: Exception) {
rethrowExactly(code, body, e)
}
suspend inline fun <T> HttpStatement.parseString(crossinline block: suspend String.() -> T) = execute { response ->
logcat(tag = TAG) { response.request.url.toString() }
val body = response.bodyAsText()
runSuspendCatching {
block(body)
}.onFailure {
rethrowExactly(response.status.value, body.left(), it)
}.getOrThrow()
}
suspend inline fun <T> HttpStatement.parseByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = execute { response ->
logcat(tag = TAG) { response.request.url.toString() }
httpContentPool.useInstance { buffer ->
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
buffer.flip()
runSuspendCatching {
block(buffer)
}.onFailure {
rethrowExactly(response.status.value, buffer.right(), it)
}.getOrThrow()
}
}
@@ -199,37 +227,37 @@ object EhEngine {
suspend fun getTorrentList(url: String, gid: Long, token: String?): TorrentResult {
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return fetchCompat(url, referer, parser = TorrentParser::parse)
return statement(url, referer).parseByteBuffer(TorrentParser::parse)
}
suspend fun getArchiveList(url: String, gid: Long, token: String?) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.executeAndParsingWith(ArchiveParser::parse)
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) }
suspend fun getArchiveList(url: String, gid: Long, token: String?) = statement(url, EhUrl.getGalleryDetailUrl(gid, token))
.parseString(ArchiveParser::parse)
.apply { funds = funds ?: statement(EhUrl.URL_FUNDS).parseString(HomeParser::parseFunds) }
suspend fun getImageLimits() = parZip(
{ fetchCompat(EhUrl.URL_HOME, parser = HomeParser::parse) },
{ ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) },
{ statement(EhUrl.URL_HOME).parseByteBuffer(HomeParser::parse) },
{ statement(EhUrl.URL_FUNDS).parseString(HomeParser::parseFunds) },
{ limits, funds -> HomeParser.Result(limits, funds) },
)
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
.executeAndParsingWith { if (parse) EventPaneParser.parse(this) else null }
suspend fun getNews(parse: Boolean) = statement(EhUrl.URL_NEWS, EhUrl.REFERER_E)
.parseString { if (parse) EventPaneParser.parse(this) else null }
suspend fun getProfile(): ProfileParser.Result {
val url = ehRequest(EhUrl.URL_FORUMS).executeAndParsingWith(ForumsParser::parse)
return ehRequest(url, EhUrl.URL_FORUMS).executeAndParsingWith(ProfileParser::parse)
val url = statement(EhUrl.URL_FORUMS).parseString(ForumsParser::parse)
return statement(url, EhUrl.URL_FORUMS).parseString(ProfileParser::parse)
}
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
runSuspendCatching {
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
}.onFailure {
statement(url).parseString { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
}.onFailure { throwable ->
// It may get redirected when accessing ex for the first time
if (url == EhUrl.URL_UCONFIG_EX) {
it.printStackTrace()
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
throwable.printStackTrace()
statement(url).parseString { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
} else {
throw it
throw throwable
}
}
}
@@ -240,54 +268,55 @@ object EhEngine {
token: String?,
): GalleryPageParser.Result {
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return ehRequest(url, referer).executeAndParsingWith(GalleryPageParser::parse)
return statement(url, referer).parseString(GalleryPageParser::parse)
}
suspend fun getGalleryList(url: String) = fetchCompat(url, EhUrl.referer, parser = GalleryListParser::parse)
suspend fun getGalleryList(url: String) = statement(url, EhUrl.referer).parseByteBuffer(GalleryListParser::parse)
.apply { fillGalleryList(galleryInfoList, url, true) }
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
EventPaneParser.parse(this)?.let {
suspend fun getGalleryDetail(url: String) = statement(url, EhUrl.referer).parseString {
val eventPane = EventPaneParser.parse(this)
if (eventPane != null) {
Settings.lastDawnDay = today
showEventNotification(it)
showEventNotification(eventPane)
}
GalleryDetailParser.parse(this)
}
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
suspend fun getPreviewList(url: String) = statement(url, EhUrl.referer).parseString {
GalleryDetailParser.parsePreviewList(this) to GalleryDetailParser.parsePreviewPages(this)
}
suspend fun getFavorites(url: String) = fetchCompat(url, EhUrl.referer, parser = FavoritesParser::parse)
suspend fun getFavorites(url: String) = statement(url, EhUrl.referer).parseByteBuffer(FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
suspend fun signIn(username: String, password: String): String {
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
val url = EhUrl.API_SIGN_IN
val origin = "https://forums.e-hentai.org"
return ehRequest(url, referer, origin) {
return statement(url, referer, origin) {
formBody {
add("referer", referer)
add("b", "")
add("bt", "")
add("UserName", username)
add("PassWord", password)
add("CookieDate", "1")
append("referer", referer)
append("b", "")
append("bt", "")
append("UserName", username)
append("PassWord", password)
append("CookieDate", "1")
}
}.executeAndParsingWith(SignInParser::parse)
}.parseString(SignInParser::parse)
}
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = statement(url, url, EhUrl.origin) {
formBody {
if (id == -1L) {
add("commenttext_new", comment)
append("commenttext_new", comment)
} else {
add("commenttext_edit", comment)
add("edit_comment", id.toString())
append("commenttext_edit", comment)
append("edit_comment", id.toString())
}
}
}.executeAndParsingWith {
}.parseString {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
@@ -303,14 +332,15 @@ object EhEngine {
else -> throw EhException("Invalid dstCat: $dstCat")
}
val url = EhUrl.getAddFavorites(gid, token)
val body = formBody {
add("favcat", catStr)
add("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
add("apply", "Apply Changes")
add("update", "1")
}
fetchCompat(url, url, EhUrl.origin, body) { }
statement(url, url, EhUrl.origin) {
formBody {
append("favcat", catStr)
append("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
append("apply", "Apply Changes")
append("update", "1")
}
}.execute { }
}
suspend fun downloadArchive(
@@ -328,26 +358,26 @@ object EhEngine {
}
val url = EhUrl.getDownloadArchive(gid, token, or)
val referer = EhUrl.getGalleryDetailUrl(gid, token)
val request = ehRequest(url, referer, EhUrl.origin) {
val request = statement(url, referer, EhUrl.origin) {
formBody {
if (isHAtH) {
add("hathdl_xres", res)
append("hathdl_xres", res)
} else {
add("dltype", res)
append("dltype", res)
if (res == "org") {
add("dlcheck", "Download Original Archive")
append("dlcheck", "Download Original Archive")
} else {
add("dlcheck", "Download Resample Archive")
append("dlcheck", "Download Resample Archive")
}
}
}
}
var result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
var result = request.parseString(ArchiveParser::parseArchiveUrl)
if (!isHAtH) {
if (result == null) {
// Wait for the server to prepare archives
delay(1000)
result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
result = request.parseString(ArchiveParser::parseArchiveUrl)
if (result == null) {
throw EhException("Archive unavailable")
}
@@ -357,11 +387,11 @@ object EhEngine {
return null
}
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
suspend fun resetImageLimits() = statement(EhUrl.URL_HOME) {
formBody {
add("reset_imagelimit", "Reset Limit")
append("reset_imagelimit", "Reset Limit")
}
}.executeAndParsingWith(HomeParser::parseResetLimits)
}.parseString(HomeParser::parseResetLimits)
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavoritesParser.Result {
val url = ehUrl {
@@ -375,12 +405,12 @@ object EhEngine {
in 0..9 -> "fav$dstCat"
else -> throw EhException("Invalid dstCat: $dstCat")
}
val body = formBody {
add("ddact", catStr)
gidArray.forEach { add("modifygids[]", it.toString()) }
}
return fetchCompat(url, url, EhUrl.origin, body, FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
return statement(url, url, EhUrl.origin) {
formBody {
append("ddact", catStr)
gidArray.forEach { append("modifygids[]", it.toString()) }
}
}.parseByteBuffer(FavoritesParser::parse).apply { fillGalleryList(galleryInfoList, url) }
}
suspend fun getGalleryPageApi(
@@ -391,7 +421,7 @@ object EhEngine {
previousPToken: String?,
): GalleryPageParser.Result {
val referer = if (index > 0 && previousPToken != null) EhUrl.getPageUrl(gid, index - 1, previousPToken) else null
return ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
return statement(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "showpage")
put("gid", gid)
@@ -399,7 +429,7 @@ object EhEngine {
put("imgkey", pToken)
put("showkey", showKey)
}
}.executeAndParsingWith { GalleryPageParser.parse(filterNot { it == '\\' }) }
}.parseString { GalleryPageParser.parse(filterNot { it == '\\' }) }
}
suspend fun rateGallery(
@@ -408,7 +438,7 @@ object EhEngine {
gid: Long,
token: String?,
rating: Float,
): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
): RateGalleryResult = statement(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
jsonBody {
put("method", "rategallery")
put("apiuid", apiUid)
@@ -417,11 +447,11 @@ object EhEngine {
put("token", token)
put("rating", ceil((rating * 2).toDouble()).toInt())
}
}.executeAndParsingWith(String::parseAs)
}.parseString(String::parseAs)
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) =
galleryInfoList.chunked(MAX_REQUEST_SIZE).parMap(concurrency = Settings.multiThreadDownload) {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
statement(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
@@ -434,7 +464,7 @@ object EhEngine {
}
put("namespace", 1)
}
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
}.parseString { GalleryApiParser.parse(this, it) }
}
suspend fun voteComment(
@@ -444,7 +474,7 @@ object EhEngine {
token: String?,
commentId: Long,
commentVote: Int,
): VoteCommentResult = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
): VoteCommentResult = statement(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "votecomment")
put("apiuid", apiUid)
@@ -454,7 +484,7 @@ object EhEngine {
put("comment_id", commentId)
put("comment_vote", commentVote)
}
}.executeAndParsingWith(String::parseAs)
}.parseString(String::parseAs)
suspend fun voteTag(
apiUid: Long,
@@ -463,7 +493,7 @@ object EhEngine {
token: String?,
tags: String?,
vote: Int,
) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
) = statement(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "taggallery")
put("apiuid", apiUid)
@@ -473,9 +503,9 @@ object EhEngine {
put("tags", tags)
put("vote", vote)
}
}.executeAndParsingWith(VoteTagParser::parse)
}.parseString(VoteTagParser::parse)
suspend fun getGalleryToken(gid: Long, gtoken: String?, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
suspend fun getGalleryToken(gid: Long, gtoken: String?, page: Int) = statement(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "gtoken")
array("pagelist") {
@@ -486,7 +516,7 @@ object EhEngine {
}
}
}
}.executeAndParsingWith(GalleryTokenApiParser::parse)
}.parseString(GalleryTokenApiParser::parse)
/**
* @param image Must be jpeg
@@ -506,7 +536,6 @@ object EhEngine {
).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl) }
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean = false) {
// Filter title and uploader
if (filter) list.removeAllSuspend { EhFilter.filterTitle(it) || EhFilter.filterUploader(it) }
var hasTags = false
@@ -526,11 +555,16 @@ object EhEngine {
val needApi = filter && EhFilter.needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
if (needApi) fillGalleryListByApi(list, url)
// Filter tag, thumbnail mode need filter uploader again
if (filter) list.removeAllSuspend { EhFilter.filterUploader(it) || EhFilter.filterTag(it) || EhFilter.filterTagNamespace(it) }
if (filter) {
list.removeAllSuspend {
EhFilter.filterUploader(it) || EhFilter.filterTag(it) || EhFilter.filterTagNamespace(it)
}
}
}
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
galleryList.parMap(concurrency = Settings.multiThreadDownload) { (gid, token) -> modifyFavorites(gid, token, dstCat) }
galleryList.parMap(concurrency = Settings.multiThreadDownload) { (gid, token) ->
modifyFavorites(gid, token, dstCat)
}
}
}
@@ -15,31 +15,31 @@
*/
package com.hippo.ehviewer.client
import android.util.Log
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.okHttpClient
import com.hippo.ehviewer.Settings
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.header
import io.ktor.client.request.prepareRequest
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.ParametersBuilder
import io.ktor.http.content.TextContent
import io.ktor.http.userAgent
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArrayBuilder
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.Call
import okhttp3.FormBody
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.executeAsync
import okio.BufferedSource
@@ -52,69 +52,17 @@ inline fun ehRequest(url: String, referer: String? = null, origin: String? = nul
origin?.let { addHeader("Origin", it) }
}.apply(builder).build()
inline fun formBody(builder: FormBody.Builder.() -> Unit) = FormBody.Builder().apply(builder).build()
inline fun Request.Builder.formBody(builder: FormBody.Builder.() -> Unit) = post(FormBody.Builder().apply(builder).build())
inline fun multipartBody(builder: MultipartBody.Builder.() -> Unit) = MultipartBody.Builder().apply(builder).build()
val MEDIA_TYPE_JSON: MediaType = "application/json; charset=utf-8".toMediaType()
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
inline fun Request.Builder.jsonBody(builder: JsonObjectBuilder.() -> Unit) = post(buildJsonObject(builder).toString().toRequestBody(MEDIA_TYPE_JSON))
const val TAG_CALL = "CancellableCallReadingScope"
// This scoped response reading scope suspend function aimed at cancelling the call immediately once coroutine is cancelled.
// i.e., bind a call with a suspended coroutine, and launch another coroutine to execute reading
// Once origin coroutine is cancelled, then call cancelled, socket is closed, the reading coroutine will encounter [IOException]
// Since Okio is not suspendable/cancellable, also okhttp, [executeAsync] only make [Request -> Response] cancellable, reading a Response is still not cancellable
// Considering interrupting is not safe and performant, See https://github.com/Kotlin/kotlinx.coroutines/issues/3551#issuecomment-1353245978, we have such [usingCancellable]
// TODO: Remove logging after this function stable
suspend inline fun <R> Call.usingCancellable(crossinline block: suspend Response.() -> R): R = executeAsync().use {
val call = this
Log.d(TAG_CALL, "Got response of call$call")
coroutineScope {
suspendCancellableCoroutine<R> { cont ->
launch {
val r = runCatching {
Log.d(TAG_CALL, "Reading response of call$call")
block(it)
}
if (!isCanceled() && cont.isActive) {
r.exceptionOrNull()?.let {
Log.e(TAG_CALL, "Reading response of call$call failed!")
cont.resumeWithException(it)
}
r.getOrNull()?.let {
Log.d(TAG_CALL, "Reading response of call$call succeed!")
cont.resume(it)
}
} else if (isCanceled() && cont.isActive) {
Log.e(TAG_CALL, "call$call cancelled but coroutine is Active!")
} else if (!isCanceled() && !cont.isActive) {
Log.e(TAG_CALL, "call$call not cancelled but coroutine is Dead!")
} else {
Log.d(TAG_CALL, "Reading response of call$call cancelled")
}
}
cont.invokeOnCancellation {
Log.d(TAG_CALL, "Cancelling call$call")
cancel()
}
}
}.apply {
Log.d(TAG_CALL, "Reading response of call$call finished!")
}
}
suspend inline fun <R> Request.execute(block: Response.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return okHttpClient.newCall(this).executeAsync().use(block)
}
suspend inline fun <R> Request.executeNonCache(crossinline block: suspend Response.() -> R) = baseOkHttpClient.newCall(this).usingCancellable(block)
suspend inline fun <R> Request.executeNoRedirect(block: Response.() -> R) = noRedirectOkHttpClient.newCall(this).executeAsync().use(block)
suspend inline fun <reified T> Request.executeAndParseAs() = execute { parseAs<T>() }
@@ -128,3 +76,30 @@ val json = Json { ignoreUnknownKeys = true }
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
suspend inline fun statement(
url: String,
referer: String? = null,
origin: String? = null,
builder: HttpRequestBuilder.() -> Unit = {},
) = ktorClient.prepareRequest(url) {
method = HttpMethod.Get
header("Referer", referer)
header("Origin", origin)
userAgent(Settings.userAgent)
header(HttpHeaders.Accept, CHROME_ACCEPT)
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
apply(builder)
}
fun HttpRequestBuilder.formBody(builder: ParametersBuilder.() -> Unit) {
method = HttpMethod.Post
val parameters = ParametersBuilder().apply(builder).build()
setBody(FormDataContent(parameters))
}
fun HttpRequestBuilder.jsonBody(builder: JsonObjectBuilder.() -> Unit) {
method = HttpMethod.Post
val json = buildJsonObject(builder).toString()
setBody(TextContent(text = json, contentType = ContentType.Application.Json))
}
@@ -1,13 +1,10 @@
package com.hippo.ehviewer.ktbuilder
import com.chuckerteam.chucker.api.ChuckerInterceptor
import okhttp3.Cache
import okhttp3.OkHttpClient
import okio.FileSystem
import okio.Path
import splitties.init.appCtx
inline fun httpClient(builder: OkHttpClient.Builder.() -> Unit): OkHttpClient = OkHttpClient.Builder().apply(builder).build()
inline fun httpClient(client: OkHttpClient, builder: OkHttpClient.Builder.() -> Unit): OkHttpClient = client.newBuilder().apply(builder).build()
fun OkHttpClient.Builder.cache(directory: Path, maxSize: Long, fileSystem: FileSystem = FileSystem.SYSTEM) = cache(Cache(directory, maxSize, fileSystem))
inline fun OkHttpClient.Builder.chunker(builder: ChuckerInterceptor.Builder.() -> Unit) = addInterceptor(ChuckerInterceptor.Builder(appCtx).apply(builder).build())
@@ -21,9 +21,8 @@ import com.hippo.ehviewer.EhApplication.Companion.imageCache as sCache
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.executeNonCache
import com.hippo.ehviewer.client.getImageKey
import com.hippo.ehviewer.client.statement
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.coil.suspendEdit
import com.hippo.ehviewer.cronet.copyToChannel
@@ -39,13 +38,15 @@ import com.hippo.ehviewer.util.isCronetSupported
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.openOutputStream
import io.ktor.client.plugins.onDownload
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.nio.copyTo
import java.util.Locale
import kotlin.io.path.readText
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Response
import okio.Buffer
import okio.ForwardingSink
import okio.sink
class SpiderDen(mGalleryInfo: GalleryInfo) {
private val mGid = mGalleryInfo.gid
@@ -141,7 +142,7 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
val headers = info.getHeadersMap()
val type = headers["Content-Type"]?.first()?.toMediaType()?.subtype ?: "jpg"
val length = headers["Content-Length"]!!.first().toLong()
saveResponseMeta(index, type, length) { file ->
saveResponseMeta(index, type) { file ->
file.openOutputStream().use { os ->
val chan = os.channel
chan.truncate(0)
@@ -152,17 +153,23 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
}
}
} else {
ehRequest(url, referer).executeNonCache {
if (code >= 400) {
false
statement(url, referer) {
var prev = 0L
onDownload { done, total ->
notifyProgress(total!!, done, (done - prev).toInt())
prev = done
}
}.execute {
if (it.status.isSuccess()) {
saveFromHttpResponse(index, it)
} else {
saveFromHttpResponse(index, this, notifyProgress)
false
}
}
}
}
private suspend fun saveResponseMeta(index: Int, ext: String, length: Long, fops: suspend (UniFile) -> Unit): Boolean {
private suspend fun saveResponseMeta(index: Int, ext: String, fops: suspend (UniFile) -> Unit): Boolean {
findDownloadFileForIndex(index, ext)?.run {
fops(this)
return true
@@ -179,19 +186,14 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
return false
}
private suspend fun saveFromHttpResponse(index: Int, response: Response, notifyProgress: (Long, Long, Int) -> Unit): Boolean {
val contentType = response.body.contentType()
val extension = contentType?.subtype ?: "jpg"
val length = response.body.contentLength()
return saveResponseMeta(index, extension, length) { outFile ->
object : ForwardingSink(outFile.openOutputStream().also { it.channel.truncate(0) }.sink()) {
var totalBytesRead = 0L
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
totalBytesRead += byteCount
notifyProgress(length, totalBytesRead, byteCount.toInt())
}
}.use { sink -> response.body.source().use { source -> source.readAll(sink) } }
private suspend fun saveFromHttpResponse(index: Int, response: HttpResponse): Boolean {
val contentType = response.contentType()
val extension = contentType?.contentSubtype ?: "jpg"
return saveResponseMeta(index, extension) { outFile ->
outFile.openOutputStream().use {
val chan = it.channel.apply { truncate(0) }
response.bodyAsChannel().copyTo(chan)
}
}
}
@@ -29,14 +29,14 @@ import com.hippo.ehviewer.client.EhUrl.getGalleryDetailUrl
import com.hippo.ehviewer.client.EhUrl.getGalleryMultiPageViewerUrl
import com.hippo.ehviewer.client.EhUrl.referer
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.client.exception.QuotaExceededException
import com.hippo.ehviewer.client.execute
import com.hippo.ehviewer.client.parseString
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePages
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewList
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewPages
import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerPTokenParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
import com.hippo.ehviewer.client.statement
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.unifile.UniFile
@@ -363,15 +363,14 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private suspend fun readSpiderInfoFromInternet(): SpiderInfo? {
return runSuspendCatching {
ehRequest(
statement(
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
referer,
).execute {
val bodyStr = body.string()
val pages = parsePages(bodyStr)
).parseString {
val pages = parsePages(this)
val spiderInfo = SpiderInfo(galleryInfo.gid, pages)
spiderInfo.token = galleryInfo.token
readPreviews(bodyStr, 0, spiderInfo)
readPreviews(this, 0, spiderInfo)
spiderInfo
}
}.onFailure {
@@ -386,9 +385,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
galleryInfo.token!!,
)
return try {
ehRequest(url, referer).execute {
val bodyStr = body.string()
GalleryMultiPageViewerPTokenParser.parse(bodyStr).forEachIndexed { index, s ->
statement(url, referer).parseString {
GalleryMultiPageViewerPTokenParser.parse(this).forEachIndexed { index, s ->
spiderInfo.pTokenMap[index] = s
}
spiderInfo.pTokenMap[index]
@@ -419,8 +417,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
false,
)
return try {
ehRequest(url, referer).execute {
readPreviews(body.string(), previewIndex, spiderInfo)
statement(url, referer).parseString {
readPreviews(this, previewIndex, spiderInfo)
spiderInfo.pTokenMap[index]
}
} catch (e: Throwable) {
+3 -8
View File
@@ -17,7 +17,6 @@ androidx-recyclerview = "1.3.2"
androidx-room = "2.6.1"
androidx-work = "2.9.0"
arrow = "1.2.1"
chucker = "4.0.0"
coil = "2.5.0"
compose-bom = "2023.12.00-alpha03"
compose-compiler = "1.5.5-dev-k2.0.0-Beta1-06b8ae672a4"
@@ -30,7 +29,7 @@ kotlinx-datetime = "0.5.0"
kotlinx-serialization = "1.6.2"
ksp = "2.0.0-Beta1-1.0.15"
ktlint = "1.0.1"
ktor = "2.3.6"
ktor = "3.0.0-beta-1"
okhttp3 = "5.0.0-alpha.11"
okio = "3.6.0"
splitties = "3.0.0"
@@ -69,9 +68,6 @@ androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref
arrow-fx-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines" }
arrow-stack = { module = "io.arrow-kt:arrow-stack", version.ref = "arrow" }
chucker = { module = "com.github.chuckerteam.chucker:library", version.ref = "chucker" }
chucker-nop = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" }
coil-bom = { module = "io.coil-kt:coil-bom", version.ref = "coil" }
coil-compose = { module = "io.coil-kt:coil-compose" }
coil-gif = { module = "io.coil-kt:coil-gif" }
@@ -107,8 +103,7 @@ kotlinx-serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-seri
# For renovate
ktlint = { module = "com.pinterest:ktlint", version.ref = "ktlint" }
ktor-io-jvm = { module = "io.ktor:ktor-io-jvm", version.ref = "ktor" }
ktor-utils-jvm = { module = "io.ktor:ktor-utils-jvm", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
material = { module = "com.google.android.material:material", version.ref = "google-android-material" }
@@ -136,7 +131,7 @@ coil = ["coil-compose", "coil-gif"]
compose = ["compose-foundation", "compose-material-icons-extended", "compose-material3", "compose-material3-windowsizeclass", "compose-ui-util"]
cronet = ["cronet-api", "cronet-gms"]
kotlinx-serialization = ["kotlinx-serialization-cbor", "kotlinx-serialization-json", "kotlinx-serialization-json-okio"]
ktor = ["ktor-io-jvm", "ktor-utils-jvm"]
ktor = ["ktor-client-okhttp"]
okhttp = ["okhttp-android", "okhttp-coroutines"]
splitties = ["splitties-appctx", "splitties-arch-room", "splitties-preferences", "splitties-systemservices"]