Bring back ktor CIO for image downloading
This commit is contained in:
1 parent
3f4efc51c9
commit
5c18dad1d8
9 files changed
+159
-219
No files matched your search
@@ -1,66 +0,0 @@
|
||||
@file:JvmName("GifDecodeUtils")
|
||||
|
||||
package coil.decode
|
||||
|
||||
import kotlin.experimental.and
|
||||
import okio.BufferedSource
|
||||
import okio.ByteString.Companion.encodeUtf8
|
||||
|
||||
// https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
|
||||
private val GIF_HEADER_87A = "GIF87a".encodeUtf8()
|
||||
private val GIF_HEADER_89A = "GIF89a".encodeUtf8()
|
||||
|
||||
// https://developers.google.com/speed/webp/docs/riff_container
|
||||
private val WEBP_HEADER_RIFF = "RIFF".encodeUtf8()
|
||||
private val WEBP_HEADER_WEBP = "WEBP".encodeUtf8()
|
||||
private val WEBP_HEADER_VPX8 = "VP8X".encodeUtf8()
|
||||
|
||||
// https://nokiatech.github.io/heif/technical.html
|
||||
private val HEIF_HEADER_FTYP = "ftyp".encodeUtf8()
|
||||
private val HEIF_HEADER_MSF1 = "msf1".encodeUtf8()
|
||||
private val HEIF_HEADER_HEVC = "hevc".encodeUtf8()
|
||||
private val HEIF_HEADER_HEVX = "hevx".encodeUtf8()
|
||||
|
||||
/**
|
||||
* Return 'true' if the [source] contains a GIF image. The [source] is not consumed.
|
||||
*/
|
||||
fun DecodeUtils.isGif(source: BufferedSource): Boolean {
|
||||
return source.rangeEquals(0, GIF_HEADER_89A) ||
|
||||
source.rangeEquals(0, GIF_HEADER_87A)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return 'true' if the [source] contains a WebP image. The [source] is not consumed.
|
||||
*/
|
||||
fun DecodeUtils.isWebP(source: BufferedSource): Boolean {
|
||||
return source.rangeEquals(0, WEBP_HEADER_RIFF) &&
|
||||
source.rangeEquals(8, WEBP_HEADER_WEBP)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return 'true' if the [source] contains an animated WebP image. The [source] is not consumed.
|
||||
*/
|
||||
fun DecodeUtils.isAnimatedWebP(source: BufferedSource): Boolean {
|
||||
return isWebP(source) &&
|
||||
source.rangeEquals(12, WEBP_HEADER_VPX8) &&
|
||||
source.request(17) &&
|
||||
(source.buffer[16] and 0b00000010) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Return 'true' if the [source] contains an HEIF image. The [source] is not consumed.
|
||||
*/
|
||||
fun DecodeUtils.isHeif(source: BufferedSource): Boolean {
|
||||
return source.rangeEquals(4, HEIF_HEADER_FTYP)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return 'true' if the [source] contains an animated HEIF image sequence. The [source] is not
|
||||
* consumed.
|
||||
*/
|
||||
fun DecodeUtils.isAnimatedHeif(source: BufferedSource): Boolean {
|
||||
return isHeif(source) &&
|
||||
(source.rangeEquals(8, HEIF_HEADER_MSF1) ||
|
||||
source.rangeEquals(8, HEIF_HEADER_HEVC) ||
|
||||
source.rangeEquals(8, HEIF_HEADER_HEVX))
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
@file:Suppress("SameParameterValue")
|
||||
|
||||
package coil.decode
|
||||
|
||||
import okio.Buffer
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
|
||||
/**
|
||||
* A [ForwardingSource] that rewrites the GIF frame delay in every graphics control block if it's
|
||||
* below a threshold.
|
||||
*/
|
||||
internal class FrameDelayRewritingSource(delegate: Source) : ForwardingSource(delegate) {
|
||||
|
||||
// An intermediary buffer so we can read and alter the data before it's written to the destination.
|
||||
private val buffer = Buffer()
|
||||
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
// Ensure our buffer has enough bytes to satisfy this read.
|
||||
request(byteCount)
|
||||
|
||||
// Short circuit if there are no bytes in the buffer.
|
||||
if (buffer.size == 0L) {
|
||||
return if (byteCount == 0L) 0L else -1L
|
||||
}
|
||||
|
||||
// Search through the buffer and rewrite any frame delays below the threshold.
|
||||
var bytesWritten = 0L
|
||||
while (true) {
|
||||
val index = indexOf(FRAME_DELAY_START_MARKER)
|
||||
if (index == -1L) break
|
||||
|
||||
// Write up until the end of the frame delay start marker.
|
||||
bytesWritten += write(sink, index + FRAME_DELAY_START_MARKER_SIZE_BYTES)
|
||||
|
||||
// Check for the end of the graphics control extension block.
|
||||
if (!request(5) || buffer[4] != 0.toByte()) continue
|
||||
|
||||
// Rewrite the frame delay if it is below the threshold.
|
||||
// The frame delay is stored as two unsigned bytes in reverse order
|
||||
// (i.e. the most significant bits are in the byte that's read second).
|
||||
val frameDelay = (buffer[2].toUByte().toInt() shl 8) or buffer[1].toUByte().toInt()
|
||||
if (frameDelay < MINIMUM_FRAME_DELAY) {
|
||||
sink.writeByte(buffer[0].toInt())
|
||||
sink.writeByte(DEFAULT_FRAME_DELAY)
|
||||
sink.writeByte(0)
|
||||
buffer.skip(3)
|
||||
}
|
||||
}
|
||||
|
||||
// Write anything left in the source.
|
||||
if (bytesWritten < byteCount) {
|
||||
bytesWritten += write(sink, byteCount - bytesWritten)
|
||||
}
|
||||
return if (bytesWritten == 0L) -1 else bytesWritten
|
||||
}
|
||||
|
||||
private fun indexOf(bytes: ByteString): Long {
|
||||
var index = -1L
|
||||
while (true) {
|
||||
index = buffer.indexOf(bytes[0], index + 1)
|
||||
if (index == -1L) break
|
||||
if (request(bytes.size.toLong()) && buffer.rangeEquals(index, bytes)) break
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
private fun write(sink: Buffer, byteCount: Long): Long {
|
||||
return buffer.read(sink, byteCount).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun request(byteCount: Long): Boolean {
|
||||
if (buffer.size >= byteCount) return true
|
||||
val toRead = byteCount - buffer.size
|
||||
return super.read(buffer, toRead) == toRead
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
|
||||
// See: "Graphics Control Extension"
|
||||
private val FRAME_DELAY_START_MARKER = "0021F904".decodeHex()
|
||||
private const val FRAME_DELAY_START_MARKER_SIZE_BYTES = 4
|
||||
private const val MINIMUM_FRAME_DELAY = 2
|
||||
private const val DEFAULT_FRAME_DELAY = 10
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,16 @@ import com.hippo.yorozuya.IntIdGenerator
|
||||
import eu.kanade.tachiyomi.core.preference.AndroidPreferenceStore
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
@@ -231,6 +237,53 @@ class EhApplication : Application(), DefaultLifecycleObserver, ImageLoaderFactor
|
||||
builder.build()
|
||||
}
|
||||
|
||||
val ktorClient by lazy {
|
||||
HttpClient(CIO) {
|
||||
engine {
|
||||
proxy = when (Settings.proxyType) {
|
||||
EhProxySelector.TYPE_DIRECT -> Proxy.NO_PROXY
|
||||
EhProxySelector.TYPE_SYSTEM -> null
|
||||
EhProxySelector.TYPE_HTTP -> {
|
||||
val ip = Settings.proxyIp
|
||||
val port = Settings.proxyPort
|
||||
val iNetAddress = InetAddress.getByName(ip)
|
||||
val socketAddress = InetSocketAddress(iNetAddress, port)
|
||||
Proxy(Proxy.Type.HTTP, socketAddress)
|
||||
}
|
||||
// CIO does not support Socks proxy yet
|
||||
else -> null
|
||||
}
|
||||
https {
|
||||
serverName = "0.0.0.0".takeIf { Settings.dF }
|
||||
trustManager = @SuppressLint("CustomX509TrustManager")
|
||||
object : X509TrustManager {
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkClientTrusted(
|
||||
p0: Array<out X509Certificate>?,
|
||||
p1: String?
|
||||
) {
|
||||
}
|
||||
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkServerTrusted(
|
||||
p0: Array<out X509Certificate>?,
|
||||
p1: String?
|
||||
) {
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate?> {
|
||||
return arrayOfNulls(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
pipelining = true
|
||||
}
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val galleryDetailCache by lazy {
|
||||
LruCache<Long, GalleryDetail>(25).also {
|
||||
|
||||
@@ -18,6 +18,8 @@ package com.hippo.ehviewer.client
|
||||
import com.hippo.ehviewer.EhApplication
|
||||
import com.hippo.network.CookieDatabase
|
||||
import com.hippo.network.CookieSet
|
||||
import io.ktor.client.plugins.cookies.CookiesStorage
|
||||
import io.ktor.http.Url
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
@@ -25,7 +27,9 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import java.util.Collections
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object EhCookieStore : CookieJar {
|
||||
typealias KtorCookie = io.ktor.http.Cookie
|
||||
|
||||
object EhCookieStore : CookieJar, CookiesStorage {
|
||||
private val db: CookieDatabase = CookieDatabase(EhApplication.application, "okhttp3-cookie.db")
|
||||
private val map: MutableMap<String, CookieSet> = db.allCookies
|
||||
|
||||
@@ -178,9 +182,19 @@ object EhCookieStore : CookieJar {
|
||||
db.clear()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun close() {
|
||||
db.close()
|
||||
override suspend fun addCookie(requestUrl: Url, cookie: io.ktor.http.Cookie) {}
|
||||
|
||||
// This should never be closed
|
||||
override fun close() {}
|
||||
|
||||
private val okhttpCookieToKtorCookieMap = hashMapOf<Cookie, KtorCookie>()
|
||||
override suspend fun get(requestUrl: Url): List<io.ktor.http.Cookie> {
|
||||
return loadForRequest(requestUrl.toString().toHttpUrl()).map {
|
||||
okhttpCookieToKtorCookieMap[it] ?: KtorCookie(
|
||||
it.name,
|
||||
it.value
|
||||
).apply { okhttpCookieToKtorCookieMap[it] = this }
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.hippo.okhttp.ChromeRequestBuilder
|
||||
import io.ktor.http.HttpMessageBuilder
|
||||
|
||||
class EhRequestBuilder @JvmOverloads constructor(
|
||||
url: String,
|
||||
@@ -27,3 +28,6 @@ class EhRequestBuilder @JvmOverloads constructor(
|
||||
origin?.let { addHeader("Origin", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun HttpMessageBuilder.referer(value: String?): Unit =
|
||||
value?.let { headers.append("Referer", it) } ?: Unit
|
||||
@@ -18,39 +18,40 @@ package com.hippo.ehviewer.spider
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.ParcelFileDescriptor.MODE_READ_WRITE
|
||||
import coil.decode.DecodeUtils
|
||||
import coil.decode.FrameDelayRewritingSource
|
||||
import coil.decode.isGif
|
||||
import coil.disk.DiskCache
|
||||
import com.hippo.ehviewer.EhApplication
|
||||
import com.hippo.ehviewer.EhApplication.Companion.application
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCacheKeyFactory
|
||||
import com.hippo.ehviewer.client.EhRequestBuilder
|
||||
import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.referer
|
||||
import com.hippo.ehviewer.coil.edit
|
||||
import com.hippo.ehviewer.coil.read
|
||||
import com.hippo.ehviewer.gallery.SUPPORT_IMAGE_EXTENSIONS
|
||||
import com.hippo.image.Image
|
||||
import com.hippo.image.Image.CloseableSource
|
||||
import com.hippo.sendTo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openOutputStream
|
||||
import com.hippo.yorozuya.FileUtils
|
||||
import com.hippo.yorozuya.MathUtils
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentLength
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.utils.io.jvm.nio.copyTo
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.executeAsync
|
||||
import okio.buffer
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.channels.FileChannel
|
||||
import java.util.Locale
|
||||
import kotlin.io.path.readText
|
||||
|
||||
private val client = EhApplication.okHttpClient
|
||||
private val client = EhApplication.ktorClient
|
||||
|
||||
class SpiderDen(private val mGalleryInfo: GalleryInfo) {
|
||||
private val mGid: Long = mGalleryInfo.gid
|
||||
@@ -168,61 +169,62 @@ class SpiderDen(private val mGalleryInfo: GalleryInfo) {
|
||||
referer: String?,
|
||||
notifyProgress: (Long, Long, Int) -> Unit
|
||||
): Boolean {
|
||||
val call = client.newCall(EhRequestBuilder(url, referer).build())
|
||||
call.executeAsync().use { response ->
|
||||
if (response.code >= 400) return false
|
||||
val body = response.body
|
||||
val extension = body.contentType()?.subtype ?: "jpg"
|
||||
val length = body.contentLength()
|
||||
|
||||
suspend fun doSave(chan: FileChannel): Long {
|
||||
var receivedSize: Long = 0
|
||||
body.source().use { responseSource ->
|
||||
val source = if (DecodeUtils.isGif(responseSource)) FrameDelayRewritingSource(
|
||||
responseSource
|
||||
).buffer() else responseSource
|
||||
source.use {
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val bytesRead = chan.transferFrom(it, receivedSize, TRANSFER_BLOCK)
|
||||
receivedSize += bytesRead
|
||||
notifyProgress(body.contentLength(), receivedSize, bytesRead.toInt())
|
||||
if (bytesRead.toInt() == 0) break
|
||||
}
|
||||
}
|
||||
}
|
||||
return receivedSize
|
||||
return client.prepareGet(url) {
|
||||
var state: Long = 0
|
||||
referer(referer)
|
||||
onDownload { bytesSentTotal, contentLength ->
|
||||
notifyProgress(contentLength, bytesSentTotal, (bytesSentTotal - state).toInt())
|
||||
state = bytesSentTotal
|
||||
}
|
||||
}.execute {
|
||||
if (it.status.value >= 400) return@execute false
|
||||
saveFromHttpResponse(index, it)
|
||||
}
|
||||
}
|
||||
|
||||
findDownloadFileForIndex(index, extension)?.runSuspendCatching {
|
||||
openOutputStream().use { outputStream ->
|
||||
outputStream.channel.use { return doSave(it) == length }
|
||||
}
|
||||
}?.onFailure {
|
||||
it.printStackTrace()
|
||||
return false
|
||||
}
|
||||
private suspend fun saveFromHttpResponse(index: Int, body: HttpResponse): Boolean {
|
||||
val contentType = body.contentType()
|
||||
val extension = contentType?.contentSubtype ?: "jpg"
|
||||
val length = body.contentLength() ?: return false
|
||||
|
||||
// Read Mode, allow save to cache
|
||||
if (mMode == SpiderQueen.MODE_READ) {
|
||||
val key = EhCacheKeyFactory.getImageKey(mGid, index)
|
||||
var received: Long = 0
|
||||
runSuspendCatching {
|
||||
sCache.edit(key) {
|
||||
metadata.toFile().writeText(extension)
|
||||
data.toFile().outputStream().use { outputStream ->
|
||||
outputStream.channel.use { received = doSave(it) }
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}.onSuccess {
|
||||
return received == length
|
||||
suspend fun doSave(outFile: UniFile): Long {
|
||||
val ret: Long
|
||||
outFile.openOutputStream().use { outputStream ->
|
||||
outputStream.channel.use {
|
||||
ret = body.bodyAsChannel().copyTo(it)
|
||||
}
|
||||
}
|
||||
if (contentType == ContentType.Image.GIF)
|
||||
outFile.openFileDescriptor("rw").use {
|
||||
Image.rewriteGifSource2(it.fd)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
findDownloadFileForIndex(index, extension)?.runSuspendCatching {
|
||||
return doSave(this) == length
|
||||
}?.onFailure {
|
||||
it.printStackTrace()
|
||||
return false
|
||||
}
|
||||
|
||||
// Read Mode, allow save to cache
|
||||
if (mMode == SpiderQueen.MODE_READ) {
|
||||
val key = EhCacheKeyFactory.getImageKey(mGid, index)
|
||||
var received: Long = 0
|
||||
runSuspendCatching {
|
||||
sCache.edit(key) {
|
||||
metadata.toFile().writeText(extension)
|
||||
received = doSave(UniFile.fromFile(data.toFile())!!)
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}.onSuccess {
|
||||
return received == length
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun saveToUniFile(index: Int, file: UniFile): Boolean {
|
||||
|
||||
@@ -100,6 +100,9 @@ class Image private constructor(private val src: CloseableSource) {
|
||||
|
||||
@JvmStatic
|
||||
external fun rewriteGifSource(buffer: ByteBuffer)
|
||||
|
||||
@JvmStatic
|
||||
external fun rewriteGifSource2(fd: Int)
|
||||
}
|
||||
|
||||
interface CloseableSource : AutoCloseable {
|
||||
|
||||
Reference in new issue
Block a user