diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 76bb1cc81..9d2557a15 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -210,6 +210,7 @@ dependencies { // https://coil-kt.github.io/coil/changelog/ implementation("io.coil-kt:coil-compose:2.2.2") + implementation("io.ktor:ktor-client-cio:2.2.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") implementation("org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.5.0") implementation("org.jsoup:jsoup:1.15.4") diff --git a/app/src/main/cpp/gifutils.c b/app/src/main/cpp/gifutils.c index 277038eea..37e194b62 100644 --- a/app/src/main/cpp/gifutils.c +++ b/app/src/main/cpp/gifutils.c @@ -2,6 +2,8 @@ #include #include #include +#include +#include #define LOG_TAG "gifUtils" @@ -22,10 +24,7 @@ static inline bool isGif(void *addr) { return !memcmp(addr, GIF_HEADER_87A, 6) || !memcmp(addr, GIF_HEADER_89A, 6); } -JNIEXPORT void JNICALL -Java_com_hippo_image_Image_rewriteGifSource(JNIEnv *env, jclass clazz, jobject buffer) { - byte *addr = (*env)->GetDirectBufferAddress(env, buffer); - size_t size = (*env)->GetDirectBufferCapacity(env, buffer); +static void doRewrite(byte *addr, size_t size) { if (size < 7 || !isGif(addr)) return; for (size_t i = 0; i < size - 8; i++) { // TODO: Optimize this hex find with SIMD? @@ -42,3 +41,21 @@ Java_com_hippo_image_Image_rewriteGifSource(JNIEnv *env, jclass clazz, jobject b } } } + +JNIEXPORT void JNICALL +Java_com_hippo_image_Image_rewriteGifSource(JNIEnv *env, jclass clazz, jobject buffer) { + byte *addr = (*env)->GetDirectBufferAddress(env, buffer); + size_t size = (*env)->GetDirectBufferCapacity(env, buffer); + doRewrite(addr, size); +} + +JNIEXPORT void JNICALL +Java_com_hippo_image_Image_rewriteGifSource2(JNIEnv *env, jclass clazz, jint fd) { + struct stat64 st; + fstat64(fd, &st); + size_t size = st.st_size; + byte *addr = mmap64(0, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (addr == MAP_FAILED) return; + doRewrite(addr, size); + munmap(addr, size); +} diff --git a/app/src/main/java/coil/decode/DecodeUtils.kt b/app/src/main/java/coil/decode/DecodeUtils.kt deleted file mode 100644 index 630931f2e..000000000 --- a/app/src/main/java/coil/decode/DecodeUtils.kt +++ /dev/null @@ -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)) -} diff --git a/app/src/main/java/coil/decode/FrameDelayRewritingSource.kt b/app/src/main/java/coil/decode/FrameDelayRewritingSource.kt deleted file mode 100644 index e2d75cd80..000000000 --- a/app/src/main/java/coil/decode/FrameDelayRewritingSource.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/com/hippo/ehviewer/EhApplication.kt b/app/src/main/java/com/hippo/ehviewer/EhApplication.kt index 6e29c8357..921236867 100644 --- a/app/src/main/java/com/hippo/ehviewer/EhApplication.kt +++ b/app/src/main/java/com/hippo/ehviewer/EhApplication.kt @@ -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?, + p1: String? + ) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted( + p0: Array?, + p1: String? + ) { + } + + override fun getAcceptedIssuers(): Array { + return arrayOfNulls(0) + } + } + } + pipelining = true + } + install(HttpCookies) { + storage = EhCookieStore + } + } + } + @JvmStatic val galleryDetailCache by lazy { LruCache(25).also { diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhCookieStore.kt b/app/src/main/java/com/hippo/ehviewer/client/EhCookieStore.kt index d4acab206..66e872b73 100644 --- a/app/src/main/java/com/hippo/ehviewer/client/EhCookieStore.kt +++ b/app/src/main/java/com/hippo/ehviewer/client/EhCookieStore.kt @@ -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 = 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() + override suspend fun get(requestUrl: Url): List { + return loadForRequest(requestUrl.toString().toHttpUrl()).map { + okhttpCookieToKtorCookieMap[it] ?: KtorCookie( + it.name, + it.value + ).apply { okhttpCookieToKtorCookieMap[it] = this } + } } override fun saveFromResponse(url: HttpUrl, cookies: List) { diff --git a/app/src/main/java/com/hippo/ehviewer/client/EhRequestBuilder.kt b/app/src/main/java/com/hippo/ehviewer/client/EhRequestBuilder.kt index 6bd8a9582..ea1c78af7 100644 --- a/app/src/main/java/com/hippo/ehviewer/client/EhRequestBuilder.kt +++ b/app/src/main/java/com/hippo/ehviewer/client/EhRequestBuilder.kt @@ -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 diff --git a/app/src/main/java/com/hippo/ehviewer/spider/SpiderDen.kt b/app/src/main/java/com/hippo/ehviewer/spider/SpiderDen.kt index 6dc1ed3f3..b8a53af74 100644 --- a/app/src/main/java/com/hippo/ehviewer/spider/SpiderDen.kt +++ b/app/src/main/java/com/hippo/ehviewer/spider/SpiderDen.kt @@ -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 { diff --git a/app/src/main/java/com/hippo/image/Image.kt b/app/src/main/java/com/hippo/image/Image.kt index 53c4a66ea..fbbaf64f3 100644 --- a/app/src/main/java/com/hippo/image/Image.kt +++ b/app/src/main/java/com/hippo/image/Image.kt @@ -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 {