diff --git a/app/build.gradle b/app/build.gradle index 4cedca019..57e5e5210 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -84,6 +84,11 @@ android { applicationId "io.legado.play" manifestPlaceholders = [APP_CHANNEL_VALUE: "google"] } + cronet { + dimension "mode" + applicationId "io.legado.cronet" + manifestPlaceholders = [APP_CHANNEL_VALUE: "cronet"] + } } compileOptions { // Flag to enable support for the new language APIs @@ -117,8 +122,8 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' implementation fileTree(dir: 'libs', include: ['*.jar','*.aar']) testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test:runner:1.3.0' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' implementation 'androidx.multidex:multidex:2.0.1' //kotlin implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" @@ -129,7 +134,7 @@ dependencies { //androidX implementation 'androidx.appcompat:appcompat:1.3.0' - implementation 'androidx.core:core-ktx:1.5.0' + implementation 'androidx.core:core-ktx:1.6.0' implementation "androidx.activity:activity-ktx:1.2.3" implementation "androidx.fragment:fragment-ktx:1.3.5" implementation 'androidx.preference:preference-ktx:1.1.1' @@ -170,7 +175,7 @@ dependencies { //规则相关 implementation 'org.jsoup:jsoup:1.14.1' //noinspection GradleDependency - implementation 'cn.wanghaomiao:JsoupXpath:2.3.2' + implementation 'cn.wanghaomiao:JsoupXpath:2.4.3' implementation 'com.jayway.jsonpath:json-path:2.6.0' //JS rhino @@ -178,6 +183,11 @@ dependencies { //网络 implementation 'com.squareup.okhttp3:okhttp:4.9.1' + def cronet_version='92.0.4509.1' + compileOnly "org.microg:cronet-api:$cronet_version" + cronetImplementation "org.microg:cronet-native:$cronet_version" + cronetImplementation "org.microg:cronet-api:$cronet_version" + cronetImplementation "org.microg:cronet-common:$cronet_version" //Glide implementation 'com.github.bumptech.glide:glide:4.12.0' diff --git a/app/src/main/java/io/legado/app/help/AppConfig.kt b/app/src/main/java/io/legado/app/help/AppConfig.kt index 4dc5d3877..2a6c469b3 100644 --- a/app/src/main/java/io/legado/app/help/AppConfig.kt +++ b/app/src/main/java/io/legado/app/help/AppConfig.kt @@ -10,6 +10,7 @@ import splitties.init.appCtx @Suppress("MemberVisibilityCanBePrivate") object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { val isGooglePlay = appCtx.channel == "google" + val isCronet= appCtx.channel=="cronet" var userAgent: String = getPrefUserAgent() var isEInkMode = appCtx.getPrefString(PreferKey.themeMode) == "3" var clickActionTL = appCtx.getPrefInt(PreferKey.clickActionTL, 2) diff --git a/app/src/main/java/io/legado/app/help/JsExtensions.kt b/app/src/main/java/io/legado/app/help/JsExtensions.kt index cab0b6d96..1f464db47 100644 --- a/app/src/main/java/io/legado/app/help/JsExtensions.kt +++ b/app/src/main/java/io/legado/app/help/JsExtensions.kt @@ -292,7 +292,7 @@ interface JsExtensions { val bos = ByteArrayOutputStream() val zis = ZipInputStream(ByteArrayInputStream(bytes)) - var entry: ZipEntry = zis.nextEntry + var entry: ZipEntry? = zis.nextEntry while (entry != null) { if (entry.name.equals(path)) { @@ -303,7 +303,7 @@ interface JsExtensions { } Debug.log("getZipContent 未发现内容") - return ""; + return "" } /** @@ -319,17 +319,17 @@ interface JsExtensions { val bos = ByteArrayOutputStream() val zis = ZipInputStream(ByteArrayInputStream(bytes)) - var entry: ZipEntry = zis.nextEntry + var entry: ZipEntry? = zis.nextEntry while (entry != null) { if (entry.name.equals(path)) { zis.use { it.copyTo(bos) } return bos.toByteArray() } - entry = zis.nextEntry; + entry = zis.nextEntry } Debug.log("getZipContent 未发现内容") - return null; + return null } /** diff --git a/app/src/main/java/io/legado/app/help/http/HttpHelper.kt b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt index cdf6a03bc..26ce5ee7c 100644 --- a/app/src/main/java/io/legado/app/help/http/HttpHelper.kt +++ b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt @@ -1,5 +1,7 @@ package io.legado.app.help.http +import io.legado.app.help.AppConfig +import io.legado.app.help.http.cronet.CronetInterceptor import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.ConnectionSpec import okhttp3.Credentials @@ -41,6 +43,10 @@ val okHttpClient: OkHttpClient by lazy { .build() chain.proceed(request) }) + if (AppConfig.isCronet){ + builder.addInterceptor(CronetInterceptor()) + } + builder.build() } diff --git a/app/src/main/java/io/legado/app/help/http/cronet/CronetHelper.kt b/app/src/main/java/io/legado/app/help/http/cronet/CronetHelper.kt new file mode 100644 index 000000000..4dac61698 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/cronet/CronetHelper.kt @@ -0,0 +1,65 @@ +package io.legado.app.help.http.cronet + +import io.legado.app.help.http.CookieStore +import okhttp3.Headers +import okhttp3.MediaType +import okhttp3.Request +import okio.Buffer +import org.chromium.net.CronetEngine.Builder.HTTP_CACHE_DISK +import org.chromium.net.ExperimentalCronetEngine +import org.chromium.net.UploadDataProviders +import org.chromium.net.UrlRequest +import splitties.init.appCtx +import java.util.concurrent.Executor +import java.util.concurrent.Executors + + +val executor: Executor by lazy { Executors.newSingleThreadExecutor() } + +val cronetEngine: ExperimentalCronetEngine by lazy { + + val builder = ExperimentalCronetEngine.Builder(appCtx) + .setStoragePath(appCtx.externalCacheDir?.absolutePath) + .enableHttpCache(HTTP_CACHE_DISK, (1024 * 1024 * 50)) + .enableQuic(true) + .enablePublicKeyPinningBypassForLocalTrustAnchors(true) + .enableHttp2(true) + //Brotli压缩 + builder.enableBrotli(true) + return@lazy builder.build() + +} + + +fun buildRequest(request: Request, callback: UrlRequest.Callback): UrlRequest { + val url = request.url.toString() + val requestBuilder = cronetEngine.newUrlRequestBuilder(url, callback, executor) + requestBuilder.setHttpMethod(request.method) + val cookie = CookieStore.getCookie(url) + if (cookie.length > 1) { + requestBuilder.addHeader("Cookie", cookie) + } + val headers: Headers = request.headers + headers.forEachIndexed { index, pair -> + requestBuilder.addHeader(headers.name(index), headers.value(index)) + } + + val requestBody = request.body + if (requestBody != null) { + val contentType: MediaType? = requestBody.contentType() + if (contentType != null) { + requestBuilder.addHeader("Content-Type", contentType.toString()) + } + val buffer = Buffer() + requestBody.writeTo(buffer) + requestBuilder.setUploadDataProvider( + UploadDataProviders.create(buffer.readByteArray()), + executor + ); + + } + + return requestBuilder.build() + +} + diff --git a/app/src/main/java/io/legado/app/help/http/cronet/CronetInterceptor.kt b/app/src/main/java/io/legado/app/help/http/cronet/CronetInterceptor.kt new file mode 100644 index 000000000..023fb97f3 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/cronet/CronetInterceptor.kt @@ -0,0 +1,20 @@ +package io.legado.app.help.http.cronet + +import okhttp3.* +import java.io.IOException + +class CronetInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + return proceedWithCronet(chain.request(), chain.call()) + } + + @Throws(IOException::class) + private fun proceedWithCronet(request: Request, call: Call): Response { + val callback = CronetUrlRequestCallback(request, call) + val urlRequest = buildRequest(request, callback) + urlRequest.start() + return callback.waitForDone() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/http/cronet/CronetUrlRequestCallback.kt b/app/src/main/java/io/legado/app/help/http/cronet/CronetUrlRequestCallback.kt new file mode 100644 index 000000000..e7e3686c0 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/http/cronet/CronetUrlRequestCallback.kt @@ -0,0 +1,207 @@ +package io.legado.app.help.http.cronet + +import android.os.ConditionVariable +import android.util.Log +import io.legado.app.help.http.okHttpClient +import okhttp3.* +import okhttp3.EventListener +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody +import org.chromium.net.CronetException +import org.chromium.net.UrlRequest +import org.chromium.net.UrlResponseInfo +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.channels.Channels +import java.util.* + +class CronetUrlRequestCallback @JvmOverloads internal constructor( + private val originalRequest: Request, + private val mCall: Call, + eventListener: EventListener? = null, + responseCallback: Callback? = null +) : UrlRequest.Callback() { + private val eventListener: EventListener? + private val responseCallback: Callback? + private var followCount = 0 + private var mResponse: Response + private var mException: IOException? = null + private val mResponseCondition = ConditionVariable() + private val mBytesReceived = ByteArrayOutputStream() + private val mReceiveChannel = Channels.newChannel(mBytesReceived) + + @Throws(IOException::class) + fun waitForDone(): Response { + mResponseCondition.block() + if (mException != null) { + throw mException as IOException + } + return mResponse + } + + override fun onRedirectReceived( + request: UrlRequest, + info: UrlResponseInfo, + newLocationUrl: String + ) { + if (followCount > MAX_FOLLOW_COUNT) { + request.cancel() + } + followCount += 1 + val client = okHttpClient + if (originalRequest.url.isHttps && newLocationUrl.startsWith("http://") && client.followSslRedirects) { + request.followRedirect() + } else if (!originalRequest.url.isHttps && newLocationUrl.startsWith("https://") && client.followSslRedirects) { + request.followRedirect() + } else if (client.followRedirects) { + request.followRedirect() + } else { + request.cancel() + } + } + + override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) { + mResponse = responseFromResponse(mResponse, info) +// val sb: StringBuilder = StringBuilder(info.url).append("\r\n") +// sb.append("[Cached:").append(info.wasCached()).append("][StatusCode:") +// .append(info.httpStatusCode).append("][StatusText:").append(info.httpStatusText) +// .append("][Protocol:").append(info.negotiatedProtocol).append("][ByteCount:") +// .append(info.receivedByteCount).append("]\r\n"); +// val httpHeaders=info.allHeadersAsList +// httpHeaders.forEach { h -> +// sb.append("[").append(h.key).append("]").append(h.value).append("\r\n"); +// } +// Log.e("Cronet", sb.toString()) + if (eventListener != null) { + eventListener.responseHeadersEnd(mCall, mResponse) + eventListener.responseBodyStart(mCall) + } + request.read(ByteBuffer.allocateDirect(32 * 1024)) + } + + @Throws(Exception::class) + override fun onReadCompleted( + request: UrlRequest, + info: UrlResponseInfo, + byteBuffer: ByteBuffer + ) { + byteBuffer.flip() + try { + mReceiveChannel.write(byteBuffer) + } catch (e: IOException) { + Log.i(TAG, "IOException during ByteBuffer read. Details: ", e) + throw e + } + byteBuffer.clear() + request.read(byteBuffer) + } + + override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { + eventListener?.responseBodyEnd(mCall, info.receivedByteCount) + val contentType: MediaType? = (mResponse.header("content-type") + ?: "text/plain; charset=\"utf-8\"").toMediaTypeOrNull() + val responseBody: ResponseBody = + mBytesReceived.toByteArray().toResponseBody(contentType) + val newRequest = originalRequest.newBuilder().url(info.url).build() + mResponse = mResponse.newBuilder().body(responseBody).request(newRequest).build() + mResponseCondition.open() + eventListener?.callEnd(mCall) + if (responseCallback != null) { + try { + responseCallback.onResponse(mCall, mResponse) + } catch (e: IOException) { + // Pass? + } + } + } + + override fun onFailed(request: UrlRequest, info: UrlResponseInfo, error: CronetException) { + val e = IOException("Cronet Exception Occurred", error) + mException = e + mResponseCondition.open() + eventListener?.callFailed(mCall, e) + responseCallback?.onFailure(mCall, e) + } + + override fun onCanceled(request: UrlRequest, info: UrlResponseInfo) { + mResponseCondition.open() + eventListener?.callEnd(mCall) + } + + companion object { + private const val TAG = "Callback" + private const val MAX_FOLLOW_COUNT = 20 + private fun protocolFromNegotiatedProtocol(responseInfo: UrlResponseInfo): Protocol { + val negotiatedProtocol = responseInfo.negotiatedProtocol.lowercase(Locale.getDefault()) +// Log.e("Cronet", responseInfo.url) +// Log.e("Cronet", negotiatedProtocol) + + return when { + negotiatedProtocol.contains("h3") -> { + return Protocol.QUIC + } + negotiatedProtocol.contains("quic") -> { + Protocol.QUIC + } + negotiatedProtocol.contains("spdy") -> { + Protocol.SPDY_3 + } + negotiatedProtocol.contains("h2") -> { + Protocol.HTTP_2 + } + negotiatedProtocol.contains("1.1") -> { + Protocol.HTTP_1_1 + } + else -> { + Protocol.HTTP_1_0 + } + } + } + + private fun headersFromResponse(responseInfo: UrlResponseInfo): Headers { + val headers = responseInfo.allHeadersAsList + val headerBuilder = Headers.Builder() + for ((key, value) in headers) { + try { + if (key.equals("content-encoding", ignoreCase = true)) { + // Strip all content encoding headers as decoding is done handled by cronet + continue + } + headerBuilder.add(key, value) + } catch (e: Exception) { + Log.w(TAG, "Invalid HTTP header/value: $key$value") + // Ignore that header + } + } + return headerBuilder.build() + } + + private fun responseFromResponse( + response: Response, + responseInfo: UrlResponseInfo + ): Response { + val protocol = protocolFromNegotiatedProtocol(responseInfo) + val headers = headersFromResponse(responseInfo) + return response.newBuilder() + .receivedResponseAtMillis(System.currentTimeMillis()) + .protocol(protocol) + .code(responseInfo.httpStatusCode) + .message(responseInfo.httpStatusText) + .headers(headers) + .build() + } + } + + init { + mResponse = Response.Builder() + .sentRequestAtMillis(System.currentTimeMillis()) + .request(originalRequest) + .protocol(Protocol.HTTP_1_0) + .code(0) + .message("") + .build() + this.responseCallback = responseCallback + this.eventListener = eventListener + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4e78684d5..2e71dab2c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.5.20' + ext.kotlin_version = '1.5.21' repositories { google() mavenCentral()