From 79787b6b53dc405e4ff36e6a417d71229b5b83d9 Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Sat, 27 Dec 2025 13:29:23 +0800 Subject: [PATCH] =?UTF-8?q?6.7.0=20-=20Alpha13=20-=20http=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E8=AF=B7=E6=B1=82=E7=9B=B8=E5=85=B3=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20timeout/isInsecure=20=E7=AD=89=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E5=8A=9F=E8=83=BD,=20=E6=94=AF=E6=8C=81=E6=9B=B4?= =?UTF-8?q?=E5=A4=9A=20body=20=E5=AF=B9=E8=B1=A1=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changelog/lang_zh-Hans.json | 6 +- .../automator/RootAutomatorNativeObject.kt | 4 +- .../autojs/runtime/api/augment/http/Http.kt | 560 +----------------- .../api/augment/http/HttpSaveResult.kt | 60 ++ .../api/augment/http/RequestBuilder.kt | 144 +++++ .../api/augment/http/RequestBuilderHelper.kt | 156 +++++ .../augment/http/ResponseBodyNativeObject.kt | 203 +++++++ .../api/augment/http/ResponseWrapper.kt | 70 +++ .../java/org/autojs/autojs/util/RhinoUtils.kt | 9 + version.properties | 6 +- 10 files changed, 662 insertions(+), 556 deletions(-) create mode 100644 app/src/main/java/org/autojs/autojs/runtime/api/augment/http/HttpSaveResult.kt create mode 100644 app/src/main/java/org/autojs/autojs/runtime/api/augment/http/RequestBuilder.kt create mode 100644 app/src/main/java/org/autojs/autojs/runtime/api/augment/http/RequestBuilderHelper.kt create mode 100644 app/src/main/java/org/autojs/autojs/runtime/api/augment/http/ResponseBodyNativeObject.kt create mode 100644 app/src/main/java/org/autojs/autojs/runtime/api/augment/http/ResponseWrapper.kt diff --git a/.changelog/lang_zh-Hans.json b/.changelog/lang_zh-Hans.json index da0fd63e..12b1af80 100644 --- a/.changelog/lang_zh-Hans.json +++ b/.changelog/lang_zh-Hans.json @@ -1,7 +1,7 @@ { "$data": { "v6.7.0": { - "released_date": "2025/12/25", + "released_date": "2025/12/26", "feature": [ "插件中心功能, 支持插件的安装/卸载/更新等操作 (入口: 主页抽屉按钮)", "cvt 模块, 用于数据单位转换 (参阅 项目文档 > [单位转换](https://docs.autojs6.com/#/cvt))", @@ -9,12 +9,12 @@ "zip 模块, 用于文件压缩与解压缩操作 (Ref to [Auto.js Pro](https://g.pro.autojs.org/)) (参阅 项目文档 > [Zip](https://docs.autojs6.com/#/zip))", "mediainfo 模块, 用于查看媒体文件的详细信息 (参阅 项目文档 > [媒体信息](https://docs.autojs6.com/#/mediainfo))", "cvt.bytes 方法, 用于字节数据单位转换 (参阅 项目文档 > [单位转换](https://docs.autojs6.com/#/cvt))", - "fmt.bytes 方法, 用于字节数据格式化 (参阅 项目文档 > [数据格式化](https://docs.autojs6.com/#/fmt))", + "fmt.bytes 方法, 用于字节数据格式化 (参阅 项目文档 > [格式化](https://docs.autojs6.com/#/fmt))", "s13n.bytes 方法, 用于标准化字节数据 (参阅 项目文档 > [标准化](https://docs.autojs6.com/#/s13n))", "app.isDualInstalled 方法, 用于检测双开应用是否已安装 (需要 Shizuku 或 Root 权限) _[`issue #450`](http://issues.autojs6.com/450)_", "device.getSharedDeviceId 方法, 用于跨应用获取统一共享设备 ID _[`issue #455`](http://issues.autojs6.com/455)_", "ui.navigationBarHeight 属性 (getter), 用于获取导航栏高度 _[`issue #456`](http://issues.autojs6.com/456)_", - "http 模块请求相关方法获取的 body 对象增加 stream/saveToFile/close 方法 _[`issue #452`](http://issues.autojs6.com/452)_", + "http 模块请求相关方法获取的 body 对象增加 stream/saveToFile/close 等方法 _[`issue #452`](http://issues.autojs6.com/452)_", "http 模块请求相关方法支持缓存控制选项参数 (cacheBody/bodyCacheThresholdBytes)", "http 模块请求相关方法支持不安全选项参数 (isInsecure/insecure), 用于忽略证书相关异常 _[`issue #417`](http://issues.autojs6.com/417)_", "http 模块请求相关方法支持 options.client 选项, 用于配置 OkHttpClient.Builder (如 followRedirects 等) _[`issue #454`](http://issues.autojs6.com/454)_", diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/automator/RootAutomatorNativeObject.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/automator/RootAutomatorNativeObject.kt index 14bacdd3..e582adbd 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/augment/automator/RootAutomatorNativeObject.kt +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/automator/RootAutomatorNativeObject.kt @@ -28,8 +28,8 @@ class RootAutomatorNativeObject(scriptRuntime: ScriptRuntime, waitForReady: Any? defineProperty("__ra__", mRootAutomatorObject, READONLY or DONTENUM or PERMANENT) } - override fun has(name: String?): Boolean { - return mRootAutomatorObject.has(name) || super.has(name) + override fun has(name: String, start: Scriptable): Boolean { + return mRootAutomatorObject.has(name, start) || super.has(name, start) } override fun get(name: String, start: Scriptable): Any? { diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/Http.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/Http.kt index 45fae7d6..fa19d456 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/Http.kt +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/Http.kt @@ -2,82 +2,46 @@ package org.autojs.autojs.runtime.api.augment.http -import android.annotation.SuppressLint -import android.webkit.MimeTypeMap import okhttp3.Call import okhttp3.Callback import okhttp3.FormBody -import okhttp3.MediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.MultipartBody -import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.asRequestBody -import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import okhttp3.ResponseBody -import okio.BufferedSink import org.autojs.autojs.annotation.RhinoFunctionBody import org.autojs.autojs.annotation.RhinoRuntimeFunctionInterface -import org.autojs.autojs.annotation.RhinoStandardFunctionInterface import org.autojs.autojs.core.http.MutableOkHttp import org.autojs.autojs.extension.AnyExtensions.isJsFunction import org.autojs.autojs.extension.AnyExtensions.isJsNullish import org.autojs.autojs.extension.AnyExtensions.jsBrief -import org.autojs.autojs.extension.AnyExtensions.toRuntimePath -import org.autojs.autojs.extension.ArrayExtensions.toNativeArray -import org.autojs.autojs.extension.FlexibleArray import org.autojs.autojs.extension.FlexibleArray.Companion.component1 import org.autojs.autojs.extension.FlexibleArray.Companion.component2 import org.autojs.autojs.extension.FlexibleArray.Companion.component3 import org.autojs.autojs.extension.FlexibleArray.Companion.component4 import org.autojs.autojs.extension.ScriptableExtensions.prop import org.autojs.autojs.extension.ScriptableObjectExtensions.inquire -import org.autojs.autojs.pio.PFile -import org.autojs.autojs.pio.PFileInterface import org.autojs.autojs.runtime.ScriptRuntime import org.autojs.autojs.runtime.api.Mime -import org.autojs.autojs.runtime.api.StringReadable import org.autojs.autojs.runtime.api.augment.Augmentable import org.autojs.autojs.runtime.api.augment.continuation.Continuation import org.autojs.autojs.runtime.api.augment.continuation.Creator -import org.autojs.autojs.runtime.api.augment.converter.core.Bytes +import org.autojs.autojs.runtime.api.augment.http.RequestBuilder.Companion.applyOkHttpClientBuilder import org.autojs.autojs.runtime.exception.WrappedIllegalArgumentException -import org.autojs.autojs.util.RhinoUtils import org.autojs.autojs.util.RhinoUtils.UNDEFINED import org.autojs.autojs.util.RhinoUtils.coerceBoolean import org.autojs.autojs.util.RhinoUtils.coerceIntNumber import org.autojs.autojs.util.RhinoUtils.coerceLongNumber -import org.autojs.autojs.util.RhinoUtils.coerceNumber import org.autojs.autojs.util.RhinoUtils.coerceObject import org.autojs.autojs.util.RhinoUtils.coerceString import org.autojs.autojs.util.RhinoUtils.isUiThread -import org.autojs.autojs.util.RhinoUtils.js_json_parse import org.autojs.autojs.util.RhinoUtils.js_json_stringify import org.autojs.autojs.util.RhinoUtils.newNativeObject -import org.autojs.autojs.util.RhinoUtils.withRhinoContext import org.mozilla.javascript.BaseFunction import org.mozilla.javascript.Context -import org.mozilla.javascript.Function -import org.mozilla.javascript.NativeArray import org.mozilla.javascript.NativeObject -import org.mozilla.javascript.Scriptable import org.mozilla.javascript.ScriptableObject.DONTENUM import org.mozilla.javascript.ScriptableObject.PERMANENT import org.mozilla.javascript.ScriptableObject.READONLY -import org.mozilla.javascript.Undefined import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.lang.reflect.Method -import java.net.URI -import java.security.SecureRandom -import java.security.cert.X509Certificate -import java.util.concurrent.TimeUnit -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManager -import javax.net.ssl.X509TrustManager @Suppress("unused", "UNUSED_PARAMETER") class Http(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime) { @@ -97,20 +61,21 @@ class Http(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime) { companion object : Augmentable() { - private const val METHOD_GET = "GET" - private const val METHOD_POST = "POST" + internal const val KEY_METHOD = "method" + internal const val KEY_CONTENT_TYPE = "contentType" + internal const val KEY_HEADERS = "headers" + internal const val KEY_FILES = "files" + internal const val KEY_BODY = "body" + internal const val KEY_CLIENT = "client" + internal const val KEY_TIMEOUT = "timeout" - private const val KEY_CLIENT = "client" - private const val KEY_METHOD = "method" - private const val KEY_CONTENT_TYPE = "contentType" - private const val KEY_HEADERS = "headers" - private const val KEY_FILES = "files" - private const val KEY_BODY = "body" - private const val KEY_TIMEOUT = "timeout" private const val KEY_MAX_RETRIES = "maxRetries" private const val KEY_CACHE_BODY = "cacheBody" private const val KEY_BODY_CACHE_THRESHOLD_BYTES = "bodyCacheThresholdBytes" + private const val METHOD_GET = "GET" + private const val METHOD_POST = "POST" + private val DEFAULT_CONTENT_TYPE = Mime.APPLICATION_X_WWW_FORM_URLENCODED @JvmField @@ -164,7 +129,7 @@ class Http(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime) { applyOkHttpClientBuilder(opt) } - val cacheBody = opt.inquire(listOf(KEY_CACHE_BODY), ::coerceBoolean, DEFAULT_CACHE_BODY) + val cacheBody = opt.inquire(KEY_CACHE_BODY, ::coerceBoolean, DEFAULT_CACHE_BODY) val cacheThreshold = coerceLongNumber(opt.prop(KEY_BODY_CACHE_THRESHOLD_BYTES), DEFAULT_BODY_CACHE_THRESHOLD_BYTES) val newCall = scriptRuntime.http.client().newCall(buildRequestRhinoWithRuntime(scriptRuntime, url, options)) @@ -288,507 +253,6 @@ class Http(scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime) { } } - private fun MutableOkHttp.applyOkHttpClientBuilder(opt: NativeObject) { - val clientProp = opt.prop(KEY_CLIENT).takeUnless { it.isJsNullish() } - require(clientProp is NativeObject?) { "Argument \"client\" ${clientProp.jsBrief()} for http.request must be a JavaScript Object" } - clientProp ?: return - - val timeout = coerceLongNumber(opt.prop(KEY_TIMEOUT), DEFAULT_TIMEOUT) - val isInsecure = opt.inquire(listOf("isInsecure", "insecure"), ::coerceBoolean, false) - - val builder = this.client().newBuilder() - .readTimeout(timeout, TimeUnit.MILLISECONDS) - .writeTimeout(timeout, TimeUnit.MILLISECONDS) - .connectTimeout(timeout, TimeUnit.MILLISECONDS) - - val builderClass = OkHttpClient.Builder::class.java - - fun coerceArg(paramType: Class<*>, value: Any?) = when { - paramType == java.lang.Boolean.TYPE || paramType == java.lang.Boolean::class.java -> coerceBoolean(value, false) - paramType == java.lang.Long.TYPE || paramType == java.lang.Long::class.java -> coerceLongNumber(value, 0L) - paramType == Integer.TYPE || paramType == Integer::class.java -> coerceIntNumber(value, 0) - paramType == java.lang.Double.TYPE || paramType == java.lang.Double::class.java -> coerceNumber(value, 0.0) - paramType == String::class.java -> coerceString(value, "") - paramType == TimeUnit::class.java -> when (value) { - is TimeUnit -> value - is String -> runCatching { TimeUnit.valueOf(value.trim().uppercase()) }.getOrElse { - throw WrappedIllegalArgumentException("Invalid TimeUnit string: $value") - } - else -> throw WrappedIllegalArgumentException("Invalid TimeUnit argument: ${value.jsBrief()}") - } - // Java object: Proxy/Dispatcher/ConnectionPool/Authenticator/SSLSocketFactory/... - paramType.isInstance(value) -> value - // Let reflection verify the type matching by itself. - // zh-CN: 让反射自行校验类型是否匹配. - else -> value - } - - fun findCandidateMethods(name: String): List { - return builderClass.methods.filter { it.name == name && it.declaringClass == builderClass } - } - - clientProp.forEach { entry -> - val (rawKey, rawVal) = entry - val methodName = coerceString(rawKey, "").trim() - if (methodName.isEmpty()) return@forEach - - val candidates = findCandidateMethods(methodName) - require(candidates.isNotEmpty()) { - "No such Builder method: $methodName on ${builderClass.name}" - } - - val zeroArg = candidates.firstOrNull { it.parameterCount == 0 } - require(zeroArg == null) { - "Builder method \"$methodName\" with 0 parameter is not allowed to be invoked via client options" - } - val twoArgs = candidates.firstOrNull { it.parameterCount == 2 } - if (twoArgs != null && rawVal is List<*> && rawVal.size == 2) { - val args2 = arrayOf(coerceArg(twoArgs.parameterTypes[0], rawVal[0]), coerceArg(twoArgs.parameterTypes[1], rawVal[1])) - twoArgs.invoke(builder, args2[0], args2[1]) - return@forEach - } - - val oneArg = candidates.firstOrNull { it.parameterCount == 1 } - if (oneArg != null) { - val paramTypes = oneArg.parameterTypes - val arg0 = coerceArg(paramTypes[0], rawVal) - oneArg.invoke(builder, arg0) - return@forEach - } - - val supported = candidates.joinToString { "(${it.parameterTypes.joinToString { p -> p.simpleName }})" } - throw WrappedIllegalArgumentException( - "Builder method \"$methodName\" is not invokable with 1 or 2 parameters via client options. Supported overloads: $supported" - ) - } - - if (isInsecure) { - @SuppressLint("CustomX509TrustManager") - val trustAllCerts = arrayOf(object : X509TrustManager { - override fun checkClientTrusted(chain: Array, authType: String) = Unit - override fun checkServerTrusted(chain: Array, authType: String) = Unit - override fun getAcceptedIssuers(): Array = arrayOf() - }) - val sslContext = SSLContext.getInstance("TLS").apply { - init(null, trustAllCerts, SecureRandom()) - } - val trustManager = trustAllCerts.first() as X509TrustManager - builder.sslSocketFactory(sslContext.socketFactory, trustManager) - builder.hostnameVerifier { _, _ -> true } - } - - // Apply the new Builder to the internal client. - // zh-CN: 应用新的 Builder 到内部客户端. - muteClient(builder) - } - - private class ResponseWrapper( - private val scriptRuntime: ScriptRuntime, - private val res: Response, - private val cacheBody: Boolean, - private val cacheThresholdBytes: Long, - ) { - private val mRequest = res.request - - var resBodyString: String? = null - var resBodyBytes: ByteArray? = null - - fun wrap() = newNativeObject().apply { - put("request", this, mRequest) - put("statusMessage", this, res.message) - put("statusCode", this, res.code) - put("body", this, getBody()) - put("headers", this, getHeaders()) - put("url", this, mRequest.url) - put("method", this, mRequest.method) - } - - private fun getBody(): ResponseBodyNativeObject { - - // Returns a non-null value if this response - // was passed to Callback.onResponse - // or returned from Call.execute. - val resBody = res.body!! - - return ResponseBodyNativeObject(scriptRuntime, resBody, this, cacheBody, cacheThresholdBytes).also { - it.defineFunctionProperties(arrayOf("string", "bytes", "json", "stream", "saveToFile", "close"), it.javaClass, READONLY or PERMANENT) - it.defineProperty(KEY_CONTENT_TYPE, { resBody.contentType() }, null, READONLY or PERMANENT) - } - } - - private fun getHeaders(): NativeObject { - val result = newNativeObject() - val headers = res.headers - for (i in 0 until headers.size) { - val name = headers.name(i).lowercase() - val value = headers.value(i) - if (!result.containsKey(name)) { - result.put(name, result, value) - continue - } - val list = mutableListOf() - val origin = result.prop(name) - if (origin !is NativeArray) { - list += origin - } else { - list.addAll(origin) - } - list += value - result.put(name, result, list.toNativeArray()) - } - return result - } - - } - - class HttpSaveResult @JvmOverloads constructor( - private val resultCode: Int, - private val outPath: String?, - private val bytesCopied: Long, - private val error: Throwable? = null, - ) : NativeObject(), StringReadable { - - init { - RhinoUtils.initNativeObjectPrototype(this) - defineProperty("code", { resultCode }, null, READONLY or PERMANENT) - defineProperty("path", { outPath }, null, READONLY or PERMANENT) - defineProperty("bytesCopied", { bytesCopied }, null, READONLY or PERMANENT) - defineProperty("success", { resultCode == RESULT_OK }, null, READONLY or PERMANENT) - defineProperty("error", { error }, null, READONLY or PERMANENT) - defineFunctionProperties(arrayOf("isSuccess"), javaClass, READONLY or PERMANENT) - } - - override fun toStringReadable(): String = listOf( - "${HttpSaveResult::class.java.simpleName} {", - " code: ${resultCode},", - " bytesCopied: $bytesCopied (${Bytes.string(bytesCopied.toDouble(), useSpace = true, strict = true)}),", - " path: '${outPath}',", - " error: ${error?.message?.take(256)?.let { "'$it'" }},", - "}", - ).joinToString("\n") - - companion object { - - @JvmField - val RESULT_OK = 0 - - private const val RESULT_FAILED_GENERIC = -1 - - fun ok(path: String, bytesCopied: Long) = - HttpSaveResult(RESULT_OK, path, bytesCopied, null) - - fun fail(path: String?, bytesCopied: Long, e: Throwable?) = - HttpSaveResult(RESULT_FAILED_GENERIC, path, bytesCopied, e) - - @JvmStatic - @RhinoStandardFunctionInterface - fun isSuccess(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): Boolean = ensureArgumentsIsEmpty(args) { - val o = thisObj as HttpSaveResult - o.resultCode == RESULT_OK - } - - } - } - - @Suppress("unused") - private class ResponseBodyNativeObject( - val scriptRuntime: ScriptRuntime, - val resBody: ResponseBody, - val responseWrapper: ResponseWrapper, - private val cacheBody: Boolean, - private val cacheThresholdBytes: Long, - ) : NativeObject() { - - init { - RhinoUtils.initNativeObjectPrototype(this) - } - - // Record whether it has been explicitly closed. - // zh-CN: 记录是否已显式关闭. - @Volatile - private var closed = false - - private fun ensureOpen() { - if (closed) throw IllegalStateException("Response body already closed") - } - - private fun autoCloseIfNeeded() { - // Close immediately after reading the complete content to avoid resource leaks; - // stream() close is handled by the caller. - // zh-CN: 读取完整内容后立即关闭, 避免资源泄露; stream() 由调用者负责 close(). - if (!closed) { - runCatching { resBody.close() } - closed = true - } - } - - private fun shouldCache(lengthHint: Long?): Boolean { - if (!cacheBody) return false - if (lengthHint == null || lengthHint < 0) return true - return lengthHint <= cacheThresholdBytes - } - - companion object : FlexibleArray() { - - @JvmStatic - @RhinoStandardFunctionInterface - fun string(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): String = ensureArgumentsIsEmpty(args) { - val o = thisObj as ResponseBodyNativeObject - o.responseWrapper.resBodyString?.let { return@ensureArgumentsIsEmpty it } - o.ensureOpen() - val contentLength = runCatching { - o.resBody.contentLength() - }.getOrDefault(-1L) - val str = o.resBody.string() - if (o.shouldCache(contentLength)) { - o.responseWrapper.resBodyString = str - } - // string() can safely close after consuming the stream. - // zh-CN: string() 消费流后可安全关闭. - o.autoCloseIfNeeded() - return@ensureArgumentsIsEmpty str - } - - @JvmStatic - @RhinoStandardFunctionInterface - fun bytes(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): ByteArray = ensureArgumentsIsEmpty(args) { - val o = thisObj as ResponseBodyNativeObject - o.responseWrapper.resBodyBytes?.let { return@ensureArgumentsIsEmpty it } - o.ensureOpen() - val contentLength = runCatching { - o.resBody.contentLength() - }.getOrDefault(-1L) - val data = o.resBody.bytes() - if (o.shouldCache(contentLength)) { - o.responseWrapper.resBodyBytes = data - } - // bytes() 消费流后可安全关闭 - o.autoCloseIfNeeded() - return@ensureArgumentsIsEmpty data - } - - @JvmStatic - @RhinoStandardFunctionInterface - fun json(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): Any? = ensureArgumentsIsEmpty(args) { - val str = string(cx, thisObj, args, funObj) - runCatching { - return@ensureArgumentsIsEmpty js_json_parse(str) - }.onFailure { - throw IllegalStateException("Failed to parse JSON. Body string may be not in JSON format") - } - } - - @JvmStatic - @RhinoStandardFunctionInterface - fun stream(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): InputStream = ensureArgumentsIsEmpty(args) { - val o = thisObj as ResponseBodyNativeObject - o.ensureOpen() - // Don't auto-close; let the caller handle it, supporting streaming copy. - // zh-CN: 不自动关闭; 交给调用者处理, 支持流式拷贝. - o.resBody.byteStream() - } - - // Save directly to file (avoid loading large responses into memory). - // zh-CN: 直接保存到文件 (避免将大型响应加载到内存中). - @JvmStatic - @RhinoStandardFunctionInterface - fun saveToFile(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): HttpSaveResult = ensureArgumentsLengthInRange(args, 1..2) { argList -> - val o = thisObj as ResponseBodyNativeObject - o.ensureOpen() - - val (pathRaw, bufSizeRaw) = argList - val path = coerceString(pathRaw, "").toRuntimePath(o.scriptRuntime) - val bufSize = coerceIntNumber(bufSizeRaw, 0).let { - if (it > 0) it else 8192 - } - - val file = PFile(path) - val isDirectory = file.isDirectory || path.endsWith("/") - if (isDirectory) { - throw WrappedIllegalArgumentException("Path \"$path\" must be a file path instead of a directory path") - } - val buffer = ByteArray(bufSize) - var copied = 0L - var input: InputStream? = null - var output: OutputStream? = null - return@ensureArgumentsLengthInRange try { - input = o.resBody.byteStream() - output = file.outputStream() - while (true) { - val read = input.read(buffer) - if (read == -1) break - output.write(buffer, 0, read) - copied += read - } - output.flush() - HttpSaveResult.ok(path, copied) - } catch (e: Throwable) { - HttpSaveResult.fail(path, copied, e) - } finally { - runCatching { input?.close() } - runCatching { output?.close() } - o.autoCloseIfNeeded() - } - } - - // Explicit close. - // zh-CN: 显式关闭. - @JvmStatic - @RhinoStandardFunctionInterface - fun close(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): Undefined = ensureArgumentsIsEmpty(args) { - val o = thisObj as ResponseBodyNativeObject - if (!o.closed) { - runCatching { o.resBody.close() } - o.closed = true - } - UNDEFINED - } - } - - } - - private class RequestBuilder(private val scriptRuntime: ScriptRuntime, private val url: Any?, options: NativeObject = newNativeObject()) { - - private val mRequest = Request.Builder() - private val mRequestBuilderHelper = RequestBuilderHelper(options) - - fun build(): Request { - mRequest.url(mRequestBuilderHelper.getUrl(coerceString(url))) - mRequestBuilderHelper.setHeaders(mRequest) - mRequestBuilderHelper.setMethod(scriptRuntime, mRequest) - return mRequest.build() - } - - } - - private class RequestBuilderHelper(private val options: NativeObject) { - - @Suppress("HttpUrlsUsage") - fun getUrl(url: String) = when { - url.matches(Regex("^https?://.*")) -> url - else -> "http://$url" - } - - fun setHeaders(request: Request.Builder) { - val headers = options.prop(KEY_HEADERS) - if (headers.isJsNullish()) return - require(headers is NativeObject) { "Property headers ${headers.jsBrief()} for builder of http.request must be a JavaScript Object" } - headers.forEach { entry -> - val (key, value) = entry - when (value) { - is NativeArray -> value.forEach { setHeader(request, key, it) } - else -> setHeader(request, key, value) - } - } - } - - fun setMethod(scriptRuntime: ScriptRuntime, request: Request.Builder) { - val method = coerceString(options.prop(KEY_METHOD)) - // require(method is String) { "Property method is required for header options" } - when { - !options.prop(KEY_BODY).isJsNullish() -> { - request.method(method, parseBody()) - } - !options.prop(KEY_FILES).isJsNullish() -> { - request.method(method, parseMultipart(scriptRuntime)) - } - else -> { - request.method(method, null) - } - } - } - - fun parseBody(): RequestBody = when (val body = options.prop(KEY_BODY)) { - is RequestBody -> body - is String -> { - val mediaType = options.prop(KEY_CONTENT_TYPE).takeUnless { it.isJsNullish() } - body.toRequestBody(Context.toString(mediaType).toMediaTypeOrNull()) - } - is BaseFunction -> object : RequestBody() { - override fun contentType(): MediaType? { - val mediaType = options.prop(KEY_CONTENT_TYPE).takeUnless { it.isJsNullish() } - return Context.toString(mediaType).toMediaTypeOrNull() - } - - override fun writeTo(sink: BufferedSink) { - withRhinoContext { cx -> - body.call(cx, body, body, arrayOf(sink)) - } - } - } - else -> throw WrappedIllegalArgumentException("Unknown type of body for header options") - } - - fun parseMultipart(scriptRuntime: ScriptRuntime): MultipartBody { - val builder = MultipartBody.Builder().setType(MultipartBody.FORM) - - val files = options.prop(KEY_FILES) - if (files.isJsNullish()) return builder.build() - require(files is NativeObject) { "Property files ${files.jsBrief()} for builder of http.request must be a JavaScript Object" } - - files.forEach { entry -> - val (key, value) = entry - when (value) { - is String -> { - builder.addFormDataPart(coerceString(key), value) - } - is NativeArray -> when (value.length) { - 2L -> { - val (fileName, path) = value - val file = if (path is URI) PFile(path) else PFile(path.toRuntimePath(scriptRuntime)) - val mimeType = parseMimeType(file.extension) - val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull()) - processFile(builder, key, fileName, requestBody) - } - 3L -> { - val (fileName, mimeType, path) = value - val file = if (path is URI) PFile(path) else PFile(path.toRuntimePath(scriptRuntime)) - val requestBody = file.asRequestBody(coerceString(mimeType).toMediaTypeOrNull()) - processFile(builder, key, fileName, requestBody) - } - else -> listOf( - "Array value \"value\" for property \"files\"", - "in RequestBuilderHelper#parseMultipart", - "must be of length 2 or 3 instead of ${value.length}", - ).joinToString(" ").let { throw WrappedIllegalArgumentException(it) } - } - is PFileInterface -> { - val path = value.path - val file = PFile(path.toRuntimePath(scriptRuntime)) - val fileName = file.name - val mimeType = parseMimeType(file.extension) - val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull()) - processFile(builder, key, fileName, requestBody) - } - else -> listOf( - "Value \"value\" for property \"files\"", - "in RequestBuilderHelper#parseMultipart", - "must be either a string, an array", - "or a JavaScript object", - ).joinToString(" ").let { throw WrappedIllegalArgumentException(it) } - } - } - - return builder.build() - } - - fun parseMimeType(ext: String) = when { - ext.isNotEmpty() -> { - MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: Mime.APPLICATION_OCTET_STREAM - } - else -> Mime.APPLICATION_OCTET_STREAM - } - - private fun setHeader(request: Request.Builder, key: Any?, it: Any?) { - request.header(coerceString(key), Context.toString(it)) - } - - private fun processFile(builder: MultipartBody.Builder, key: Any?, fileName: Any?, requestBody: RequestBody) { - builder.addFormDataPart(coerceString(key), fileName as? String, requestBody) - } - - } - } } diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/HttpSaveResult.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/HttpSaveResult.kt new file mode 100644 index 00000000..b0f788d7 --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/HttpSaveResult.kt @@ -0,0 +1,60 @@ +package org.autojs.autojs.runtime.api.augment.http + +import org.autojs.autojs.annotation.RhinoStandardFunctionInterface +import org.autojs.autojs.extension.FlexibleArray.Companion.ensureArgumentsIsEmpty +import org.autojs.autojs.runtime.api.StringReadable +import org.autojs.autojs.runtime.api.augment.converter.core.Bytes +import org.autojs.autojs.util.RhinoUtils +import org.mozilla.javascript.Context +import org.mozilla.javascript.Function +import org.mozilla.javascript.NativeObject +import org.mozilla.javascript.Scriptable + +@Suppress("unused") +class HttpSaveResult @JvmOverloads constructor( + private val resultCode: Int, + private val outPath: String?, + private val bytesCopied: Long, + private val error: Throwable? = null, +) : NativeObject(), StringReadable { + + init { + RhinoUtils.initNativeObjectPrototype(this) + defineProperty("code", { resultCode }, null, READONLY or PERMANENT) + defineProperty("path", { outPath }, null, READONLY or PERMANENT) + defineProperty("bytesCopied", { bytesCopied }, null, READONLY or PERMANENT) + defineProperty("success", { resultCode == RESULT_OK }, null, READONLY or PERMANENT) + defineProperty("error", { error }, null, READONLY or PERMANENT) + defineFunctionProperties(arrayOf("isSuccess"), javaClass, READONLY or PERMANENT) + } + + override fun toStringReadable(): String = listOf( + "${HttpSaveResult::class.java.simpleName} {", + " code: ${resultCode},", + " bytesCopied: $bytesCopied (${Bytes.string(bytesCopied.toDouble(), useSpace = true, strict = true)}),", + " path: '${outPath}',", + " error: ${error?.message?.take(256)?.let { "'$it'" }},", + "}", + ).joinToString("\n") + + companion object { + + const val RESULT_OK = 0 + + private const val RESULT_FAILED_GENERIC = -1 + + fun ok(path: String, bytesCopied: Long) = + HttpSaveResult(RESULT_OK, path, bytesCopied, null) + + fun fail(path: String?, bytesCopied: Long, e: Throwable?) = + HttpSaveResult(RESULT_FAILED_GENERIC, path, bytesCopied, e) + + @JvmStatic + @RhinoStandardFunctionInterface + fun isSuccess(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): Boolean = ensureArgumentsIsEmpty(args) { + val o = thisObj as HttpSaveResult + o.resultCode == RESULT_OK + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/RequestBuilder.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/RequestBuilder.kt new file mode 100644 index 00000000..8a9fe87d --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/RequestBuilder.kt @@ -0,0 +1,144 @@ +package org.autojs.autojs.runtime.api.augment.http + +import android.annotation.SuppressLint +import okhttp3.OkHttpClient +import okhttp3.Request +import org.autojs.autojs.core.http.MutableOkHttp +import org.autojs.autojs.extension.AnyExtensions.isJsNullish +import org.autojs.autojs.extension.AnyExtensions.jsBrief +import org.autojs.autojs.extension.ScriptableExtensions.prop +import org.autojs.autojs.extension.ScriptableObjectExtensions.inquire +import org.autojs.autojs.runtime.ScriptRuntime +import org.autojs.autojs.runtime.api.augment.http.Http.Companion.DEFAULT_TIMEOUT +import org.autojs.autojs.runtime.api.augment.http.Http.Companion.KEY_CLIENT +import org.autojs.autojs.runtime.api.augment.http.Http.Companion.KEY_TIMEOUT +import org.autojs.autojs.runtime.exception.WrappedIllegalArgumentException +import org.autojs.autojs.util.RhinoUtils +import org.autojs.autojs.util.RhinoUtils.coerceBoolean +import org.autojs.autojs.util.RhinoUtils.coerceIntNumber +import org.autojs.autojs.util.RhinoUtils.coerceLongNumber +import org.autojs.autojs.util.RhinoUtils.coerceNumber +import org.autojs.autojs.util.RhinoUtils.coerceString +import org.mozilla.javascript.NativeObject +import java.lang.reflect.Method +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +class RequestBuilder( + private val scriptRuntime: ScriptRuntime, + private val url: Any?, + options: NativeObject = RhinoUtils.newNativeObject(), +) { + + private val mRequest = Request.Builder() + private val mRequestBuilderHelper = RequestBuilderHelper(options) + + fun build(): Request { + mRequest.url(mRequestBuilderHelper.getUrl(coerceString(url))) + mRequestBuilderHelper.setHeaders(mRequest) + mRequestBuilderHelper.setMethod(scriptRuntime, mRequest) + return mRequest.build() + } + + companion object { + + fun MutableOkHttp.applyOkHttpClientBuilder(opt: NativeObject) { + val clientProp = opt.prop(KEY_CLIENT).takeUnless { it.isJsNullish() } + require(clientProp is NativeObject?) { "Argument \"client\" ${clientProp.jsBrief()} for http.request must be a JavaScript Object" } + + val timeout = coerceLongNumber(opt.prop(KEY_TIMEOUT), DEFAULT_TIMEOUT) + val isInsecure = opt.inquire(listOf("isInsecure", "insecure"), ::coerceBoolean, false) + + val builder = this.client().newBuilder() + .readTimeout(timeout, TimeUnit.MILLISECONDS) + .writeTimeout(timeout, TimeUnit.MILLISECONDS) + .connectTimeout(timeout, TimeUnit.MILLISECONDS) + + val builderClass = OkHttpClient.Builder::class.java + + fun coerceArg(paramType: Class<*>, value: Any?) = when { + paramType == java.lang.Boolean.TYPE || paramType == Boolean::class.java -> coerceBoolean(value, false) + paramType == java.lang.Long.TYPE || paramType == Long::class.java -> coerceLongNumber(value, 0L) + paramType == Integer.TYPE || paramType == Int::class.java -> coerceIntNumber(value, 0) + paramType == java.lang.Double.TYPE || paramType == Double::class.java -> coerceNumber(value, 0.0) + paramType == String::class.java -> coerceString(value, "") + paramType == TimeUnit::class.java -> when (value) { + is TimeUnit -> value + is String -> runCatching { TimeUnit.valueOf(value.trim().uppercase()) }.getOrElse { + throw WrappedIllegalArgumentException("Invalid TimeUnit string: $value") + } + else -> throw WrappedIllegalArgumentException("Invalid TimeUnit argument: ${value.jsBrief()}") + } + // Java object: Proxy/Dispatcher/ConnectionPool/Authenticator/SSLSocketFactory/... + paramType.isInstance(value) -> value + // Let reflection verify the type matching by itself. + // zh-CN: 让反射自行校验类型是否匹配. + else -> value + } + + fun findCandidateMethods(name: String): List { + return builderClass.methods.filter { it.name == name && it.declaringClass == builderClass } + } + + clientProp?.forEach { entry -> + val (rawKey, rawVal) = entry + val methodName = coerceString(rawKey, "").trim() + if (methodName.isEmpty()) return@forEach + + val candidates = findCandidateMethods(methodName) + require(candidates.isNotEmpty()) { + "No such Builder method: $methodName on ${builderClass.name}" + } + + val zeroArg = candidates.firstOrNull { it.parameterCount == 0 } + require(zeroArg == null) { + "Builder method \"$methodName\" with 0 parameter is not allowed to be invoked via client options" + } + val twoArgs = candidates.firstOrNull { it.parameterCount == 2 } + if (twoArgs != null && rawVal is List<*> && rawVal.size == 2) { + val args2 = arrayOf(coerceArg(twoArgs.parameterTypes[0], rawVal[0]), coerceArg(twoArgs.parameterTypes[1], rawVal[1])) + twoArgs.invoke(builder, args2[0], args2[1]) + return@forEach + } + + val oneArg = candidates.firstOrNull { it.parameterCount == 1 } + if (oneArg != null) { + val paramTypes = oneArg.parameterTypes + val arg0 = coerceArg(paramTypes[0], rawVal) + oneArg.invoke(builder, arg0) + return@forEach + } + + val supported = candidates.joinToString { "(${it.parameterTypes.joinToString { p -> p.simpleName }})" } + throw WrappedIllegalArgumentException( + "Builder method \"$methodName\" is not invokable with 1 or 2 parameters via client options. Supported overloads: $supported" + ) + } + + if (isInsecure) { + @SuppressLint("CustomX509TrustManager") + val trustAllCerts = arrayOf(object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) = Unit + override fun checkServerTrusted(chain: Array, authType: String) = Unit + override fun getAcceptedIssuers(): Array = arrayOf() + }) + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, trustAllCerts, SecureRandom()) + } + val trustManager = trustAllCerts.first() as X509TrustManager + builder.sslSocketFactory(sslContext.socketFactory, trustManager) + builder.hostnameVerifier { _, _ -> true } + } + + // Apply the new Builder to the internal client. + // zh-CN: 应用新的 Builder 到内部客户端. + muteClient(builder) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/RequestBuilderHelper.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/RequestBuilderHelper.kt new file mode 100644 index 00000000..d9c12a1a --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/RequestBuilderHelper.kt @@ -0,0 +1,156 @@ +package org.autojs.autojs.runtime.api.augment.http + +import android.webkit.MimeTypeMap +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okio.BufferedSink +import org.autojs.autojs.extension.AnyExtensions.isJsNullish +import org.autojs.autojs.extension.AnyExtensions.isJsNumber +import org.autojs.autojs.extension.AnyExtensions.isJsString +import org.autojs.autojs.extension.AnyExtensions.jsBrief +import org.autojs.autojs.extension.AnyExtensions.toRuntimePath +import org.autojs.autojs.extension.ScriptableExtensions.prop +import org.autojs.autojs.pio.PFile +import org.autojs.autojs.pio.PFileInterface +import org.autojs.autojs.runtime.ScriptRuntime +import org.autojs.autojs.runtime.api.Mime +import org.autojs.autojs.runtime.exception.WrappedIllegalArgumentException +import org.autojs.autojs.util.RhinoUtils +import org.mozilla.javascript.BaseFunction +import org.mozilla.javascript.Context +import org.mozilla.javascript.NativeArray +import org.mozilla.javascript.NativeObject +import java.net.URI + +class RequestBuilderHelper(private val options: NativeObject) { + + @Suppress("HttpUrlsUsage") + fun getUrl(url: String) = when { + url.matches(Regex("^https?://.*")) -> url + else -> "http://$url" + } + + fun setHeaders(request: Request.Builder) { + val headers = options.prop(Http.KEY_HEADERS) + if (headers.isJsNullish()) return + require(headers is NativeObject) { "Property headers ${headers.jsBrief()} for builder of http.request must be a JavaScript Object" } + headers.forEach { entry -> + val (key, value) = entry + when (value) { + is NativeArray -> value.forEach { setHeader(request, key, it) } + else -> setHeader(request, key, value) + } + } + } + + fun setMethod(scriptRuntime: ScriptRuntime, request: Request.Builder) { + val method = RhinoUtils.coerceString(options.prop(Http.KEY_METHOD)) + // require(method is String) { "Property method is required for header options" } + when { + !options.prop(Http.KEY_BODY).isJsNullish() -> { + request.method(method, parseBody()) + } + !options.prop(Http.KEY_FILES).isJsNullish() -> { + request.method(method, parseMultipart(scriptRuntime)) + } + else -> { + request.method(method, null) + } + } + } + + fun parseBody(): RequestBody = when (val body = options.prop(Http.KEY_BODY)) { + is RequestBody -> body + is String -> { + val mediaType = options.prop(Http.KEY_CONTENT_TYPE).takeUnless { it.isJsNullish() } + body.toRequestBody(Context.toString(mediaType).toMediaTypeOrNull()) + } + is BaseFunction -> object : RequestBody() { + override fun contentType(): MediaType? { + val mediaType = options.prop(Http.KEY_CONTENT_TYPE).takeUnless { it.isJsNullish() } + return Context.toString(mediaType).toMediaTypeOrNull() + } + + override fun writeTo(sink: BufferedSink) { + RhinoUtils.withRhinoContext { cx -> + body.call(cx, body, body, arrayOf(sink)) + } + } + } + else -> throw WrappedIllegalArgumentException("Unknown type of body for header options") + } + + fun parseMultipart(scriptRuntime: ScriptRuntime): MultipartBody { + val builder = MultipartBody.Builder().setType(MultipartBody.FORM) + + val files = options.prop(Http.KEY_FILES) + if (files.isJsNullish()) return builder.build() + require(files is NativeObject) { "Property files ${files.jsBrief()} for builder of http.request must be a JavaScript Object" } + + files.forEach { entry -> + val (key, value) = entry + when { + value.isJsString() || value.isJsNumber() -> { + builder.addFormDataPart(RhinoUtils.coerceString(key), RhinoUtils.coerceString(value)) + } + value is NativeArray -> when (value.length) { + 2L -> { + val (fileName, path) = value + val file = if (path is URI) PFile(path) else PFile(path.toRuntimePath(scriptRuntime)) + val mimeType = parseMimeType(file.extension) + val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull()) + processFile(builder, key, fileName, requestBody) + } + 3L -> { + val (fileName, mimeType, path) = value + val file = if (path is URI) PFile(path) else PFile(path.toRuntimePath(scriptRuntime)) + val requestBody = file.asRequestBody(RhinoUtils.coerceString(mimeType).toMediaTypeOrNull()) + processFile(builder, key, fileName, requestBody) + } + else -> listOf( + "Array value \"value\" for property \"files\"", + "in RequestBuilderHelper#parseMultipart", + "must be of length 2 or 3 instead of ${value.length}", + ).joinToString(" ").let { throw WrappedIllegalArgumentException(it) } + } + value is PFileInterface -> { + val path = value.path + val file = PFile(path.toRuntimePath(scriptRuntime)) + val fileName = file.name + val mimeType = parseMimeType(file.extension) + val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull()) + processFile(builder, key, fileName, requestBody) + } + else -> listOf( + "Value \"value\" ${value.jsBrief()} for property \"files\"", + "in RequestBuilderHelper#parseMultipart", + "must be either a string, an array", + "or a JavaScript object", + ).joinToString(" ").let { throw WrappedIllegalArgumentException(it) } + } + } + + return builder.build() + } + + fun parseMimeType(ext: String) = when { + ext.isNotEmpty() -> { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: Mime.APPLICATION_OCTET_STREAM + } + else -> Mime.APPLICATION_OCTET_STREAM + } + + private fun setHeader(request: Request.Builder, key: Any?, it: Any?) { + request.header(RhinoUtils.coerceString(key), Context.toString(it)) + } + + private fun processFile(builder: MultipartBody.Builder, key: Any?, fileName: Any?, requestBody: RequestBody) { + builder.addFormDataPart(RhinoUtils.coerceString(key), fileName as? String, requestBody) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/ResponseBodyNativeObject.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/ResponseBodyNativeObject.kt new file mode 100644 index 00000000..3b0dde17 --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/ResponseBodyNativeObject.kt @@ -0,0 +1,203 @@ +package org.autojs.autojs.runtime.api.augment.http + +import okhttp3.ResponseBody +import org.autojs.autojs.annotation.RhinoStandardFunctionInterface +import org.autojs.autojs.extension.AnyExtensions.toRuntimePath +import org.autojs.autojs.extension.FlexibleArray +import org.autojs.autojs.extension.FlexibleArray.Companion.component1 +import org.autojs.autojs.extension.FlexibleArray.Companion.component2 +import org.autojs.autojs.extension.ScriptableExtensions.prop +import org.autojs.autojs.pio.PFile +import org.autojs.autojs.runtime.ScriptRuntime +import org.autojs.autojs.runtime.exception.WrappedIllegalArgumentException +import org.autojs.autojs.util.RhinoUtils +import org.autojs.autojs.util.RhinoUtils.getRhinoStandardFunctionMethods +import org.autojs.autojs.util.RhinoUtils.withRhinoContext +import org.mozilla.javascript.BaseFunction +import org.mozilla.javascript.BoundFunction +import org.mozilla.javascript.Context +import org.mozilla.javascript.Function +import org.mozilla.javascript.NativeObject +import org.mozilla.javascript.Scriptable +import org.mozilla.javascript.Undefined +import java.io.InputStream +import java.io.OutputStream +import org.mozilla.javascript.ScriptRuntime as RhinoScriptRuntime + +@Suppress("unused") +class ResponseBodyNativeObject( + val scriptRuntime: ScriptRuntime, + val resBody: ResponseBody, + val responseWrapper: ResponseWrapper, + private val cacheBody: Boolean, + private val cacheThresholdBytes: Long, +) : NativeObject() { + + private val mResBodyObject: Scriptable by lazy { + RhinoScriptRuntime.toObject(scriptRuntime.topLevelScope, resBody) + } + + init { + RhinoUtils.initNativeObjectPrototype(this) + } + + // Record whether it has been explicitly closed. + // zh-CN: 记录是否已显式关闭. + @Volatile + var closed = false + + override fun has(name: String, start: Scriptable): Boolean { + return mResBodyObject.has(name, start) || super.has(name, start) + } + + override fun get(name: String, start: Scriptable): Any? { + val rhinoMethods = Companion::class.java.getRhinoStandardFunctionMethods() + rhinoMethods.firstOrNull { it.name == name }?.let { + return super.get(name, start) + } + return when (val o = mResBodyObject.prop(name)) { + is BaseFunction -> withRhinoContext { cx -> + BoundFunction(cx, mResBodyObject, o, mResBodyObject, arrayOf()) + } + else -> super.get(name, start) + } + } + + fun ensureOpen() { + if (closed) throw IllegalStateException("Response body already closed") + } + + fun autoCloseIfNeeded() { + // Close immediately after reading the complete content to avoid resource leaks; + // stream() close is handled by the caller. + // zh-CN: 读取完整内容后立即关闭, 避免资源泄露; stream() 由调用者负责 close(). + if (!closed) { + runCatching { resBody.close() } + closed = true + } + } + + fun shouldCache(lengthHint: Long?): Boolean { + if (!cacheBody) return false + if (lengthHint == null || lengthHint < 0) return true + return lengthHint <= cacheThresholdBytes + } + + companion object : FlexibleArray() { + + @JvmStatic + @RhinoStandardFunctionInterface + fun string(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): String = ensureArgumentsIsEmpty(args) { + val o = thisObj as ResponseBodyNativeObject + o.responseWrapper.resBodyString?.let { return@ensureArgumentsIsEmpty it } + o.ensureOpen() + val contentLength = runCatching { + o.resBody.contentLength() + }.getOrDefault(-1L) + val str = o.resBody.string() + if (o.shouldCache(contentLength)) { + o.responseWrapper.resBodyString = str + } + // string() can safely close after consuming the stream. + // zh-CN: string() 消费流后可安全关闭. + o.autoCloseIfNeeded() + return@ensureArgumentsIsEmpty str + } + + @JvmStatic + @RhinoStandardFunctionInterface + fun bytes(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): ByteArray = ensureArgumentsIsEmpty(args) { + val o = thisObj as ResponseBodyNativeObject + o.responseWrapper.resBodyBytes?.let { return@ensureArgumentsIsEmpty it } + o.ensureOpen() + val contentLength = runCatching { + o.resBody.contentLength() + }.getOrDefault(-1L) + val data = o.resBody.bytes() + if (o.shouldCache(contentLength)) { + o.responseWrapper.resBodyBytes = data + } + // bytes() 消费流后可安全关闭 + o.autoCloseIfNeeded() + return@ensureArgumentsIsEmpty data + } + + @JvmStatic + @RhinoStandardFunctionInterface + fun json(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): Any? = ensureArgumentsIsEmpty(args) { + val str = string(cx, thisObj, args, funObj) + runCatching { + return@ensureArgumentsIsEmpty RhinoUtils.js_json_parse(str) + }.onFailure { + throw IllegalStateException("Failed to parse JSON. Body string may be not in JSON format") + } + } + + @JvmStatic + @RhinoStandardFunctionInterface + fun stream(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): InputStream = ensureArgumentsIsEmpty(args) { + val o = thisObj as ResponseBodyNativeObject + o.ensureOpen() + // Don't auto-close; let the caller handle it, supporting streaming copy. + // zh-CN: 不自动关闭; 交给调用者处理, 支持流式拷贝. + o.resBody.byteStream() + } + + // Save directly to file (avoid loading large responses into memory). + // zh-CN: 直接保存到文件 (避免将大型响应加载到内存中). + @JvmStatic + @RhinoStandardFunctionInterface + fun saveToFile(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): HttpSaveResult = ensureArgumentsLengthInRange(args, 1..2) { argList -> + val o = thisObj as ResponseBodyNativeObject + o.ensureOpen() + + val (pathRaw, bufSizeRaw) = argList + val path = RhinoUtils.coerceString(pathRaw, "").toRuntimePath(o.scriptRuntime) + val bufSize = RhinoUtils.coerceIntNumber(bufSizeRaw, 0).let { + if (it > 0) it else 8192 + } + + val file = PFile(path) + val isDirectory = file.isDirectory || path.endsWith("/") + if (isDirectory) { + throw WrappedIllegalArgumentException("Path \"$path\" must be a file path instead of a directory path") + } + val buffer = ByteArray(bufSize) + var copied = 0L + var input: InputStream? = null + var output: OutputStream? = null + return@ensureArgumentsLengthInRange try { + input = o.resBody.byteStream() + output = file.outputStream() + while (true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + copied += read + } + output.flush() + HttpSaveResult.ok(path, copied) + } catch (e: Throwable) { + HttpSaveResult.fail(path, copied, e) + } finally { + runCatching { input?.close() } + runCatching { output?.close() } + o.autoCloseIfNeeded() + } + } + + // Explicit close. + // zh-CN: 显式关闭. + @JvmStatic + @RhinoStandardFunctionInterface + fun close(cx: Context, thisObj: Scriptable, args: Array, funObj: Function): Undefined = ensureArgumentsIsEmpty(args) { + val o = thisObj as ResponseBodyNativeObject + if (!o.closed) { + runCatching { o.resBody.close() } + o.closed = true + } + RhinoUtils.UNDEFINED + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/ResponseWrapper.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/ResponseWrapper.kt new file mode 100644 index 00000000..7df024d2 --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/http/ResponseWrapper.kt @@ -0,0 +1,70 @@ +package org.autojs.autojs.runtime.api.augment.http + +import okhttp3.Response +import org.autojs.autojs.extension.ArrayExtensions.toNativeArray +import org.autojs.autojs.extension.ScriptableExtensions.prop +import org.autojs.autojs.runtime.ScriptRuntime +import org.autojs.autojs.util.RhinoUtils.newNativeObject +import org.mozilla.javascript.NativeArray +import org.mozilla.javascript.NativeObject +import org.mozilla.javascript.ScriptableObject.PERMANENT +import org.mozilla.javascript.ScriptableObject.READONLY + +class ResponseWrapper( + private val scriptRuntime: ScriptRuntime, + private val res: Response, + private val cacheBody: Boolean, + private val cacheThresholdBytes: Long, +) { + private val mRequest = res.request + + var resBodyString: String? = null + var resBodyBytes: ByteArray? = null + + fun wrap() = newNativeObject().apply { + put("request", this, mRequest) + put("statusMessage", this, res.message) + put("statusCode", this, res.code) + put("body", this, getBody()) + put("headers", this, getHeaders()) + put("url", this, mRequest.url) + put("method", this, mRequest.method) + } + + private fun getBody(): ResponseBodyNativeObject { + + // Returns a non-null value if this response + // was passed to Callback.onResponse + // or returned from Call.execute. + val resBody = res.body!! + + return ResponseBodyNativeObject(scriptRuntime, resBody, this, cacheBody, cacheThresholdBytes).also { + it.defineFunctionProperties(arrayOf("string", "bytes", "json", "stream", "saveToFile", "close"), it.javaClass, READONLY or PERMANENT) + it.defineProperty(Http.KEY_CONTENT_TYPE, { resBody.contentType() }, null, READONLY or PERMANENT) + } + } + + private fun getHeaders(): NativeObject { + val result = newNativeObject() + val headers = res.headers + for (i in 0 until headers.size) { + val name = headers.name(i).lowercase() + val value = headers.value(i) + if (!result.containsKey(name)) { + result.put(name, result, value) + continue + } + val list = mutableListOf() + val origin = result.prop(name) + if (origin !is NativeArray) { + list += origin + } else { + list.addAll(origin) + } + list += value + result.put(name, result, list.toNativeArray()) + } + return result + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/autojs/autojs/util/RhinoUtils.kt b/app/src/main/java/org/autojs/autojs/util/RhinoUtils.kt index c0c63604..caf2f2ef 100644 --- a/app/src/main/java/org/autojs/autojs/util/RhinoUtils.kt +++ b/app/src/main/java/org/autojs/autojs/util/RhinoUtils.kt @@ -7,6 +7,7 @@ import android.os.Handler import android.os.Looper import android.os.Parcelable import org.autojs.autojs.AutoJs +import org.autojs.autojs.annotation.RhinoStandardFunctionInterface import org.autojs.autojs.core.automator.UiObjectCollection import org.autojs.autojs.extension.AnyExtensions.isJsNullish import org.autojs.autojs.extension.AnyExtensions.jsBrief @@ -50,6 +51,7 @@ import org.mozilla.javascript.Wrapper import org.mozilla.javascript.json.JsonParser import java.io.Serializable import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method import java.math.BigInteger import kotlin.math.floor import kotlin.math.roundToInt @@ -909,6 +911,13 @@ object RhinoUtils { return null } + @JvmStatic + fun Class<*>.getRhinoStandardFunctionMethods(): List { + return this.declaredMethods.filter { + it.isAnnotationPresent(RhinoStandardFunctionInterface::class.java) + } + } + class ObsoletedRhinoFunctionException(funcName: String) : Exception( "Function \"$funcName\" can no longer be used as it has been obsoleted", ) diff --git a/version.properties b/version.properties index 2c1aca8c..f8e742d0 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -#Fri Dec 26 16:11:42 CST 2025 -BUILD_TIME=1766736702142 +#Sat Dec 27 13:20:38 CST 2025 +BUILD_TIME=1766812838968 COMPILE_SDK_VERSION=36 IMAGE_QUANT_CMAKE_VERSION=3.22.1 IMAGE_QUANT_NDK_VERSION=26.1.10909125 @@ -27,6 +27,6 @@ RAPID_OCR_OPENCV_MOBILE_LABEL_VERSION=13 RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3 TARGET_SDK_VERSION=36 TARGET_SDK_VERSION_INRT=29 -VERSION_BUILD=3551 +VERSION_BUILD=3554 VERSION_NAME=6.7.0 Alpha13 VSCODE_EXT_REQUIRED_VERSION=1.0.8