diff --git a/.gitattributes b/.gitattributes index 173a6f10..24600b6d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ aar/* filter=lfs diff=lfs merge=lfs -text app/aar/* filter=lfs diff=lfs merge=lfs -text +app/src/main/jniLibs/arm64-v8a filter=lfs diff=lfs merge=lfs -text +app/src/main/jniLibs/armeabi-v7a filter=lfs diff=lfs merge=lfs -text +app/src/main/jniLibs/x86 filter=lfs diff=lfs merge=lfs -text +app/src/main/jniLibs/x86_64 filter=lfs diff=lfs merge=lfs -text diff --git a/app/build.gradle b/app/build.gradle index 57a3a761..0e8c9e2e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -146,6 +146,7 @@ android { } sourceSets { main { + jniLibs.srcDirs = ['src/main/jniLibs'] assets { srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets' } diff --git a/app/src/main/java/com/futo/platformplayer/AppCaUpdater.kt b/app/src/main/java/com/futo/platformplayer/AppCaUpdater.kt new file mode 100644 index 00000000..301b6a7d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/AppCaUpdater.kt @@ -0,0 +1,43 @@ +package com.futo.platformplayer + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +object AppCaUpdater { + private const val CA_URL = "https://curl.se/ca/cacert.pem" + private const val CACHE_FILENAME = "curl-ca-bundle.pem" + private const val MAX_AGE_DAYS = 30 + + suspend fun ensureCaBundle(context: Context): File = withContext(Dispatchers.IO) { + val file = File(context.noBackupFilesDir, CACHE_FILENAME) + val needsUpdate = !file.exists() || isOlderThanDays(file, MAX_AGE_DAYS) + if (needsUpdate) { + downloadToFile(CA_URL, file) + } + return@withContext file + } + + private fun isOlderThanDays(file: File, days: Int): Boolean { + val ageMs = System.currentTimeMillis() - file.lastModified() + return ageMs > days * 24L * 60L * 60L * 1000L + } + + private fun downloadToFile(urlStr: String, dest: File) { + val conn = (URL(urlStr).openConnection() as HttpURLConnection).apply { + connectTimeout = 15000 + readTimeout = 15000 + instanceFollowRedirects = true + } + conn.inputStream.use { input -> + dest.parentFile?.mkdirs() + dest.outputStream().use { output -> + input.copyTo(output) + } + } + conn.disconnect() + } +} diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index dc2caa63..44b59152 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -33,6 +33,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.withStateAtLeast import androidx.media3.common.util.UnstableApi +import com.curlbind.Libcurl import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.R import com.futo.platformplayer.RootInsetsController @@ -275,6 +276,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { @UnstableApi override fun onCreate(savedInstanceState: Bundle?) { Logger.w(TAG, "MainActivity Starting [$mainId]"); + StateApp.instance.setGlobalContext(this, lifecycleScope, mainId); StateApp.instance.mainAppStarting(this); diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt index 40613271..41834cb0 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt @@ -36,6 +36,7 @@ import com.futo.platformplayer.engine.internal.V8Converter import com.futo.platformplayer.engine.packages.PackageBridge import com.futo.platformplayer.engine.packages.PackageDOMParser import com.futo.platformplayer.engine.packages.PackageHttp +import com.futo.platformplayer.engine.packages.PackageHttpImp import com.futo.platformplayer.engine.packages.PackageJSDOM import com.futo.platformplayer.engine.packages.PackageUtilities import com.futo.platformplayer.engine.packages.V8Package @@ -383,6 +384,7 @@ class V8Plugin { return when(packageName) { "DOMParser" -> PackageDOMParser(this) "Http" -> PackageHttp(this, config) + "HttpImp" -> PackageHttpImp(this, config) "Utilities" -> PackageUtilities(this, config) "JSDOM" -> PackageJSDOM(this, config) else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}"); diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/Libcurl.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/Libcurl.kt new file mode 100644 index 00000000..ed0b90bb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/Libcurl.kt @@ -0,0 +1,217 @@ +package com.curlbind + +import androidx.annotation.Keep +import java.io.ByteArrayOutputStream +import java.nio.charset.Charset +import kotlin.collections.iterator +import kotlin.math.min + +@Keep +object Libcurl { + init { + System.loadLibrary("curl-impersonate") + System.loadLibrary("curl-impersonate-jni") + // CURL_GLOBAL_ALL = 3 + require(ce_global_init(3) == CURLcode.CURLE_OK) { "curl_global_init failed" } + } + + @Keep + data class Request( + var url: String, + var method: String = "GET", + var headers: Map = emptyMap(), + var body: ByteArray? = null, + var impersonateTarget: String = "chrome136", + var useBuiltInHeaders: Boolean = true, + var timeoutMs: Int = 30_000, + var cookieJarPath: String? = null, + var sendCookies: Boolean = true, + var persistCookies: Boolean = true, + ) + + @Keep + data class Response( + val status: Int, + val effectiveUrl: String, + val bodyBytes: ByteArray, + val headers: Map> + ) + + object CURLcode { + const val CURLE_OK = 0 + const val CURLE_UNKNOWN_OPTION = 48 + } + + object CurlInfoConsts { + const val CURLINFO_STRING = 0x100000 + const val CURLINFO_LONG = 0x200000 + const val CURLINFO_DOUBLE = 0x300000 + const val CURLINFO_SLIST = 0x400000 + const val CURLINFO_PTR = 0x400000 + const val CURLINFO_SOCKET = 0x500000 + const val CURLINFO_OFF_T = 0x600000 + const val CURLINFO_MASK = 0x0fffff + const val CURLINFO_TYPEMASK = 0xf00000 + } + + object CURLINFO { + const val NONE = 0 + const val EFFECTIVE_URL = CurlInfoConsts.CURLINFO_STRING + 1 + const val RESPONSE_CODE = CurlInfoConsts.CURLINFO_LONG + 2 + } + + object CURLOPT { + const val URL = 10002 + const val FOLLOWLOCATION = 52 + const val MAXREDIRS = 68 + const val CONNECTTIMEOUT_MS = 156 + const val TIMEOUT_MS = 155 + const val HTTP_VERSION = 84 + const val ACCEPT_ENCODING = 10102 + const val HTTPHEADER = 10023 + const val COOKIEFILE = 10031 + const val COOKIEJAR = 10082 + const val CUSTOMREQUEST = 10036 + const val IPRESOLVE = 113 + const val POSTFIELDS = 10015 + const val POSTFIELDSIZE = 60 + const val WRITEFUNCTION = 20011 + const val HEADERFUNCTION = 20079 + const val WRITEDATA = 10001 + const val HEADERDATA = 10029 + const val COPYPOSTFIELDS = 10165 + const val CURLOPT_DNS_SERVERS = 10211 + const val CAPATH = 10097 + const val CAINFO = 10065 + } + + object CURL_HTTP_VERSION { const val TWO_TLS = 4 } + object CURL_IPRESOLVE { const val WHATEVER = 0; const val V4 = 1; const val V6 = 2 } + + @Keep interface WriteCallback { fun onWrite(chunk: ByteArray): Int } + @Keep interface HeaderCallback { fun onHeader(line: ByteArray): Int } + + @Volatile private var defaultCAPath: String? = null + @Keep fun setDefaultCAPath(path: String) { defaultCAPath = path } + + fun perform(req: Request): Response { + val easy = ce_easy_init() + require(easy != 0L) { "curl_easy_init failed" } + + var slist: Long = 0L + val bodySink = ByteArrayOutputStream(64 * 1024) + val rawHeaderLines = ArrayList(64) + + try { + val imp = ce_easy_impersonate(easy, req.impersonateTarget, req.useBuiltInHeaders) + if (imp != CURLcode.CURLE_OK && imp != CURLcode.CURLE_UNKNOWN_OPTION) { + error("curl_easy_impersonate failed: ${ce_easy_strerror(imp)}") + } + + checkOK(ce_setopt_str(easy, CURLOPT.URL, req.url)) + checkOK(ce_setopt_long(easy, CURLOPT.FOLLOWLOCATION, 1)) + checkOK(ce_setopt_long(easy, CURLOPT.MAXREDIRS, 10)) + checkOK(ce_setopt_long(easy, CURLOPT.CONNECTTIMEOUT_MS, req.timeoutMs.toLong())) + checkOK(ce_setopt_long(easy, CURLOPT.TIMEOUT_MS, req.timeoutMs.toLong())) + checkOK(ce_setopt_long(easy, CURLOPT.HTTP_VERSION, CURL_HTTP_VERSION.TWO_TLS.toLong())) + checkOK(ce_setopt_str(easy, CURLOPT.ACCEPT_ENCODING, "")) // enable auto-decompress + + if (req.headers.isNotEmpty()) { + for ((k, v) in req.headers) slist = ce_slist_append(slist, "$k: $v") + if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist)) + } + + if (req.sendCookies || req.persistCookies) { + val jar = (req.cookieJarPath ?: defaultCookieJarPath()) + if (req.sendCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEFILE, jar)) + if (req.persistCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEJAR, jar)) + } + + val method = req.method + if (!method.equals("GET", ignoreCase = true)) { + checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method)) + val body = req.body + if (body != null && body.isNotEmpty()) { + checkOK(ce_set_postfields(easy, body)) + } + } + + checkOK(ce_set_write_callback(easy, object : WriteCallback { + override fun onWrite(chunk: ByteArray): Int { + bodySink.write(chunk) + return chunk.size + } + })) + checkOK(ce_set_header_callback(easy, object : HeaderCallback { + override fun onHeader(line: ByteArray): Int { + // Keep raw but trim CRLF for convenience + val s = line.toString(Charset.forName("ISO-8859-1")).trimEnd('\r', '\n') + if (s.isNotBlank()) rawHeaderLines.add(s) + return line.size + } + })) + + checkOK(ce_setopt_str(easy, CURLOPT.CURLOPT_DNS_SERVERS, "1.1.1.1,8.8.8.8")); + defaultCAPath?.let { checkOK(ce_setopt_str(easy, CURLOPT.CAINFO, it)) } + + val rc = ce_easy_perform(easy) + if (rc != CURLcode.CURLE_OK) error("curl_easy_perform failed: ${ce_easy_strerror(rc)}") + + val codeArr = longArrayOf(0) + checkOK(ce_easy_getinfo_long(easy, CURLINFO.RESPONSE_CODE, codeArr)) + val effective = ce_easy_getinfo_string(easy, CURLINFO.EFFECTIVE_URL) ?: req.url + + return Response( + status = codeArr[0].toInt(), + effectiveUrl = effective, + bodyBytes = bodySink.toByteArray(), + headers = parseHeaders(rawHeaderLines) + ) + } finally { + if (slist != 0L) ce_slist_free_all(slist) + ce_easy_cleanup(easy) + } + } + + private fun defaultCookieJarPath(): String { + val tmp = System.getProperty("java.io.tmpdir") ?: "/data/local/tmp" + return if (tmp.endsWith("/")) "${tmp}imphttp.cookies.txt" else "$tmp/imphttp.cookies.txt" + } + + private fun checkOK(code: Int) { + if (code != CURLcode.CURLE_OK) throw IllegalStateException("libcurl error: ${ce_easy_strerror(code)}") + } + + private fun parseHeaders(lines: List): Map> { + val map = linkedMapOf>() + for (line in lines) { + val idx = line.indexOf(':') + if (idx <= 0) continue + val name = line.substring(0, idx).trim() + val value = line.substring(min(idx + 1, line.length)).trim() + map.getOrPut(name) { mutableListOf() }.add(value) + } + return map + } + + @JvmStatic external fun ce_set_write_callback(easy: Long, cb: WriteCallback?): Int + @JvmStatic external fun ce_set_header_callback(easy: Long, cb: HeaderCallback?): Int + + @JvmStatic external fun ce_global_init(flags: Long): Int + @JvmStatic external fun ce_global_cleanup() + @JvmStatic external fun ce_easy_init(): Long + @JvmStatic external fun ce_easy_cleanup(easy: Long) + @JvmStatic external fun ce_easy_perform(easy: Long): Int + + @JvmStatic external fun ce_easy_impersonate(easy: Long, target: String, defaultHeaders: Boolean): Int + @JvmStatic external fun ce_setopt_long(easy: Long, opt: Int, value: Long): Int + @JvmStatic external fun ce_setopt_str(easy: Long, opt: Int, value: String): Int + @JvmStatic external fun ce_setopt_ptr(easy: Long, opt: Int, ptr: Long): Int + @JvmStatic external fun ce_slist_append(list: Long, header: String): Long + @JvmStatic external fun ce_slist_free_all(list: Long) + @JvmStatic external fun ce_easy_getinfo_long(easy: Long, info: Int, outVal: LongArray): Int + @JvmStatic external fun ce_easy_getinfo_string(easy: Long, info: Int): String? + + @JvmStatic external fun ce_set_postfields(easy: Long, body: ByteArray): Int + @JvmStatic external fun ce_easy_strerror(code: Int): String +} diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttpImp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttpImp.kt new file mode 100644 index 00000000..89c2b1f6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttpImp.kt @@ -0,0 +1,787 @@ +package com.futo.platformplayer.engine.packages + +import com.caoccao.javet.annotations.V8Convert +import com.caoccao.javet.annotations.V8Function +import com.caoccao.javet.annotations.V8Property +import com.caoccao.javet.enums.V8ConversionMode +import com.caoccao.javet.enums.V8ProxyMode +import com.caoccao.javet.interop.V8Runtime +import com.caoccao.javet.values.V8Value +import com.caoccao.javet.values.primitive.V8ValueString +import com.caoccao.javet.values.reference.V8ValueTypedArray +import com.curlbind.Libcurl +import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.engine.IV8PluginConfig +import com.futo.platformplayer.engine.V8Plugin +import com.futo.platformplayer.engine.internal.IV8Convertable +import com.futo.platformplayer.engine.internal.V8BindObject +import com.futo.platformplayer.logging.Logger +import java.net.SocketTimeoutException +import java.nio.charset.Charset +import java.util.UUID +import java.util.concurrent.ForkJoinPool +import java.util.concurrent.ForkJoinTask +import kotlin.math.min + +class PackageHttpImp : V8Package { + @Transient + internal val _config: IV8PluginConfig + + @Transient + private val _packageClient: PackageHttpClient + + @Transient + private val _packageClientAuth: PackageHttpClient + + override val name: String get() = "HttpImp" + override val variableName: String get() = "httpimp" + + private var _batchPoolLock: Any = Any() + private var _batchPool: ForkJoinPool? = null + + private val _clients = mutableMapOf() + + constructor(plugin: V8Plugin, config: IV8PluginConfig) : super(plugin) { + _config = config + _packageClient = PackageHttpClient(this, withAuth = false) + _packageClientAuth = PackageHttpClient(this, withAuth = true) + } + + fun cleanup() { + Logger.w(TAG, "PackageHttpImp Cleaning up") + } + + private fun autoParallelPool( + data: List, + parallelism: Int, + handle: (T) -> R + ): List> { + synchronized(_batchPoolLock) { + val threadsToUse = if (parallelism <= 0) data.size else min(parallelism, data.size) + if (_batchPool == null) { + _batchPool = ForkJoinPool(threadsToUse) + } + var pool = _batchPool ?: return listOf() + if (pool.poolSize < threadsToUse) { + pool.shutdown() + _batchPool = ForkJoinPool(threadsToUse) + pool = _batchPool ?: return listOf() + } + + val resultTasks = mutableListOf>>() + for (item in data) { + resultTasks.add( + pool.submit> { + try { + Pair(handle(item), null) + } catch (ex: Throwable) { + Pair(null, ex) + } + } + ) + } + return resultTasks.map { it.join() } + } + } + + @V8Function + fun newClient(withAuth: Boolean): PackageHttpClient { + val client = PackageHttpClient(this, withAuth) + client.clientId()?.let { _clients[it] = client } + return client + } + + @V8Function + fun getDefaultClient(withAuth: Boolean): PackageHttpClient { + return if (withAuth) _packageClientAuth else _packageClient + } + + fun getClient(id: String?): PackageHttpClient { + if (id == null) throw IllegalArgumentException("Http client $id doesn't exist") + if (_packageClient.clientId() == id) return _packageClient + if (_packageClientAuth.clientId() == id) return _packageClientAuth + return _clients[id] ?: throw IllegalArgumentException("Http client $id doesn't exist") + } + + @V8Function + fun batch(): BatchBuilder { + return BatchBuilder(this) + } + + @V8Function + fun request( + method: String, + url: String, + headers: MutableMap = HashMap(), + useAuth: Boolean = false, + bytesResult: Boolean = false + ): IBridgeHttpResponse { + val client = if (useAuth) _packageClientAuth else _packageClient + return client.requestInternal( + method, + url, + headers, + if (bytesResult) ReturnType.BYTES else ReturnType.STRING + ) + } + + @V8Function + fun requestWithBody( + method: String, + url: String, + body: String, + headers: MutableMap = HashMap(), + useAuth: Boolean = false, + bytesResult: Boolean = false + ): IBridgeHttpResponse { + val client = if (useAuth) _packageClientAuth else _packageClient + return client.requestWithBodyInternal( + method, + url, + body, + headers, + if (bytesResult) ReturnType.BYTES else ReturnType.STRING + ) + } + + @V8Function + fun GET( + url: String, + headers: MutableMap = HashMap(), + useAuth: Boolean = false, + useByteResponse: Boolean = false + ): IBridgeHttpResponse { + val client = if (useAuth) _packageClientAuth else _packageClient + return client.GETInternal( + url, + headers, + if (useByteResponse) ReturnType.BYTES else ReturnType.STRING + ) + } + + @V8Function + fun POST( + url: String, + body: Any, + headers: MutableMap = HashMap(), + useAuth: Boolean = false, + useByteResponse: Boolean = false + ): IBridgeHttpResponse { + val client = if (useAuth) _packageClientAuth else _packageClient + + return when (body) { + is V8ValueString -> + client.POSTInternal(url, body.value, headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING) + is String -> + client.POSTInternal(url, body, headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING) + is V8ValueTypedArray -> + client.POSTInternal(url, body.toBytes(), headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING) + is ByteArray -> + client.POSTInternal(url, body, headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING) + is ArrayList<*> -> + client.POSTInternal( + url, + body.map { (it as Double).toInt().toByte() }.toByteArray(), + headers, + if (useByteResponse) ReturnType.BYTES else ReturnType.STRING + ) + else -> throw NotImplementedError("Body type ${body?.javaClass?.name} not implemented for POST") + } + } + + private fun logExceptions(handle: () -> T): T { + try { + return handle() + } catch (ex: Exception) { + Logger.e("Plugin[${_config.name}]", ex.message, ex) + throw ex + } + } + + interface IBridgeHttpResponse { + val url: String + val code: Int + val headers: Map>? + } + + @kotlinx.serialization.Serializable + class BridgeHttpStringResponse( + override val url: String, + override val code: Int, + val body: String?, + override val headers: Map>? = null + ) : IV8Convertable, IBridgeHttpResponse { + val isOk = code in 200..299 + + override fun toV8(runtime: V8Runtime): V8Value? { + val obj = runtime.createV8ValueObject() + obj.set("url", url) + obj.set("code", code) + obj.set("body", body) + obj.set("headers", headers) + obj.set("isOk", isOk) + return obj + } + } + + @kotlinx.serialization.Serializable + class BridgeHttpBytesResponse( + override val url: String, + override val code: Int, + val body: ByteArray? = null, + override val headers: Map>? = null + ) : IV8Convertable, IBridgeHttpResponse { + val isOk: Boolean = code in 200..299 + + override fun toV8(runtime: V8Runtime): V8Value? { + val obj = runtime.createV8ValueObject() + obj.set("url", url) + obj.set("code", code) + if (body != null) { + obj.set("body", body) + } + obj.set("headers", headers) + obj.set("isOk", isOk) + return obj + } + } + + @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class) + class BatchBuilder( + @Transient private val _package: PackageHttpImp, + existingRequests: MutableList> = mutableListOf() + ) : V8BindObject() { + @Transient + private val _reqs = existingRequests + + @V8Function + fun request( + method: String, + url: String, + headers: MutableMap = HashMap(), + useAuth: Boolean = false + ): BatchBuilder { + return clientRequest(_package.getDefaultClient(useAuth).clientId(), method, url, headers) + } + + @V8Function + fun requestWithBody( + method: String, + url: String, + body: String, + headers: MutableMap = HashMap(), + useAuth: Boolean = false + ): BatchBuilder { + return clientRequestWithBody(_package.getDefaultClient(useAuth).clientId(), method, url, body, headers) + } + + @V8Function + fun GET( + url: String, + headers: MutableMap = HashMap(), + useAuth: Boolean = false + ): BatchBuilder = + clientGET(_package.getDefaultClient(useAuth).clientId(), url, headers) + + @V8Function + fun POST( + url: String, + body: String, + headers: MutableMap = HashMap(), + useAuth: Boolean = false + ): BatchBuilder = + clientPOST(_package.getDefaultClient(useAuth).clientId(), url, body, headers) + + @V8Function + fun DUMMY(): BatchBuilder { + _reqs.add( + Pair( + _package.getDefaultClient(false), + RequestDescriptor("DUMMY", "", mutableMapOf()) + ) + ) + return BatchBuilder(_package, _reqs) + } + + @V8Function + fun clientRequest( + clientId: String?, + method: String, + url: String, + headers: MutableMap = HashMap() + ): BatchBuilder { + _reqs.add(Pair(_package.getClient(clientId), RequestDescriptor(method, url, headers))) + return BatchBuilder(_package, _reqs) + } + + @V8Function + fun clientRequestWithBody( + clientId: String?, + method: String, + url: String, + body: String, + headers: MutableMap = HashMap() + ): BatchBuilder { + _reqs.add( + Pair( + _package.getClient(clientId), + RequestDescriptor(method, url, headers, body) + ) + ) + return BatchBuilder(_package, _reqs) + } + + @V8Function + fun clientGET( + clientId: String?, + url: String, + headers: MutableMap = HashMap() + ): BatchBuilder = + clientRequest(clientId, "GET", url, headers) + + @V8Function + fun clientPOST( + clientId: String?, + url: String, + body: String, + headers: MutableMap = HashMap() + ): BatchBuilder = + clientRequestWithBody(clientId, "POST", url, body, headers) + + @V8Function + fun execute(): List { + return _package.autoParallelPool(_reqs, -1) { + if (it.second.method == "DUMMY") { + return@autoParallelPool null + } + if (it.second.body != null) { + it.first.requestWithBodyInternal( + it.second.method, + it.second.url, + it.second.body!!, + it.second.headers, + it.second.respType + ) + } else { + it.first.requestInternal( + it.second.method, + it.second.url, + it.second.headers, + it.second.respType + ) + } + }.map { + if (it.second != null) throw it.second!! + it.first + }.toList() + } + } + + @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class) + class PackageHttpClient : V8BindObject { + @Transient + private val _package: PackageHttpImp + + @Transient + private val _withAuth: Boolean + + val parentConfig: IV8PluginConfig + get() = _package._config + + @Transient + private val _defaultHeaders = mutableMapOf() + + @Transient + private val _clientId: String = UUID.randomUUID().toString() + + @Volatile + private var timeoutMs: Int = 30_000 + + @Volatile + private var sendCookies: Boolean = true + + @Volatile + private var persistCookies: Boolean = true + + @Volatile + private var cookieJarPath: String? = null + + @Volatile + private var impersonateTarget: String = "chrome136" + + @Volatile + private var useBuiltInHeaders: Boolean = true + + @V8Property + fun clientId(): String? = _clientId + + constructor(pack: PackageHttpImp, withAuth: Boolean) : super() { + _package = pack + _withAuth = withAuth + } + + private fun ensureCookieJarPath(): String { + val existing = cookieJarPath + if (existing != null) return existing + + val tmp = System.getProperty("java.io.tmpdir") ?: "/data/local/tmp" + val safeName = parentConfig.name.replace(Regex("[^a-zA-Z0-9._-]"), "_") + val fileName = + if (_withAuth) "imphttp.$safeName.auth.cookies.txt" else "imphttp.$safeName.cookies.txt" + val path = if (tmp.endsWith("/")) tmp + fileName else "$tmp/$fileName" + cookieJarPath = path + return path + } + + @V8Function + fun setDefaultHeaders(defaultHeaders: Map) { + synchronized(_defaultHeaders) { + for (pair in defaultHeaders) { + _defaultHeaders[pair.key] = pair.value + } + } + } + + @V8Function + fun setDoApplyCookies(apply: Boolean) { + sendCookies = apply + } + + @V8Function + fun setDoUpdateCookies(update: Boolean) { + persistCookies = update + } + + @V8Function + fun setDoAllowNewCookies(allow: Boolean) { + persistCookies = allow + } + + @V8Function + fun setTimeout(timeoutMs: Int) { + this.timeoutMs = timeoutMs + } + + @V8Function + fun request( + method: String, + url: String, + headers: MutableMap = HashMap(), + useBytes: Boolean = false + ): IBridgeHttpResponse = + requestInternal(method, url, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING) + + fun requestInternal( + method: String, + url: String, + headers: MutableMap = HashMap(), + returnType: ReturnType + ): IBridgeHttpResponse { + applyDefaultHeaders(headers) + return logExceptions { + catchHttp { + val resp = performCurl(method, url, headers, null) + responseToBridge(resp, returnType) + } + } + } + + @V8Function + fun requestWithBody( + method: String, + url: String, + body: String, + headers: MutableMap = HashMap(), + useBytes: Boolean = false + ): IBridgeHttpResponse = + requestWithBodyInternal( + method, + url, + body, + headers, + if (useBytes) ReturnType.BYTES else ReturnType.STRING + ) + + fun requestWithBodyInternal( + method: String, + url: String, + body: String, + headers: MutableMap = HashMap(), + returnType: ReturnType + ): IBridgeHttpResponse { + applyDefaultHeaders(headers) + return logExceptions { + catchHttp { + val resp = performCurl(method, url, headers, body.toByteArray()) + responseToBridge(resp, returnType) + } + } + } + + @V8Function + fun GET( + url: String, + headers: MutableMap = HashMap(), + useBytes: Boolean = false + ): IBridgeHttpResponse = + GETInternal(url, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING) + + fun GETInternal( + url: String, + headers: MutableMap = HashMap(), + returnType: ReturnType = ReturnType.STRING + ): IBridgeHttpResponse { + applyDefaultHeaders(headers) + return logExceptions { + catchHttp { + val resp = performCurl("GET", url, headers, null) + responseToBridge(resp, returnType) + } + } + } + + @V8Function + fun POST( + url: String, + body: Any, + headers: MutableMap = HashMap(), + useBytes: Boolean = false + ): IBridgeHttpResponse { + return when (body) { + is V8ValueString -> + POSTInternal(url, body.value, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING) + is String -> + POSTInternal(url, body, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING) + is V8ValueTypedArray -> + POSTInternal(url, body.toBytes(), headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING) + is ByteArray -> + POSTInternal(url, body, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING) + is ArrayList<*> -> + POSTInternal( + url, + body.map { (it as Double).toInt().toByte() }.toByteArray(), + headers, + if (useBytes) ReturnType.BYTES else ReturnType.STRING + ) + else -> throw NotImplementedError("Body type ${body?.javaClass?.name} not implemented for POST") + } + } + + fun POSTInternal( + url: String, + body: String, + headers: MutableMap = HashMap(), + returnType: ReturnType = ReturnType.STRING + ): IBridgeHttpResponse { + applyDefaultHeaders(headers) + return logExceptions { + catchHttp { + val resp = performCurl("POST", url, headers, body.toByteArray()) + responseToBridge(resp, returnType) + } + } + } + + fun POSTInternal( + url: String, + body: ByteArray, + headers: MutableMap = HashMap(), + returnType: ReturnType = ReturnType.STRING + ): IBridgeHttpResponse { + applyDefaultHeaders(headers) + return logExceptions { + catchHttp { + val resp = performCurl("POST", url, headers, body) + responseToBridge(resp, returnType) + } + } + } + + private fun performCurl( + method: String, + url: String, + headers: Map, + bodyBytes: ByteArray? + ): Libcurl.Response { + val jar = ensureCookieJarPath() + + val req = Libcurl.Request( + url = url, + method = method, + headers = headers, + body = bodyBytes, + impersonateTarget = impersonateTarget, + useBuiltInHeaders = useBuiltInHeaders, + timeoutMs = timeoutMs, + cookieJarPath = jar, + sendCookies = sendCookies, + persistCookies = persistCookies + ) + return Libcurl.perform(req) + } + + private fun responseToBridge( + resp: Libcurl.Response, + returnType: ReturnType + ): IBridgeHttpResponse { + val sanitizedHeaders = sanitizeResponseHeaders(resp.headers, shouldWhitelistHeaders()) + return when (returnType) { + ReturnType.STRING -> { + val bodyStr = decodeBody(resp) + BridgeHttpStringResponse(resp.effectiveUrl, resp.status, bodyStr, sanitizedHeaders) + } + ReturnType.BYTES -> { + BridgeHttpBytesResponse(resp.effectiveUrl, resp.status, resp.bodyBytes, sanitizedHeaders) + } + } + } + + private fun decodeBody(resp: Libcurl.Response): String { + if (resp.bodyBytes.isEmpty()) return "" + + val contentTypeHeader = resp.headers.entries.firstOrNull { + it.key.equals("content-type", ignoreCase = true) + }?.value?.firstOrNull() + + val charset: Charset = contentTypeHeader + ?.let { parseCharset(it) } + ?: Charsets.UTF_8 + + return String(resp.bodyBytes, charset) + } + + private fun parseCharset(contentType: String): Charset? { + val parts = contentType.split(";") + for (part in parts) { + val trimmed = part.trim() + val lower = trimmed.lowercase() + if (lower.startsWith("charset=")) { + val value = trimmed.substringAfter("=", "").trim().trim('"', '\'') + return try { + Charset.forName(value) + } catch (e: Exception) { + null + } + } + } + return null + } + + private fun shouldWhitelistHeaders(): Boolean { + val cfg = parentConfig + return !(cfg is SourcePluginConfig && cfg.allowAllHttpHeaderAccess) + } + + private fun applyDefaultHeaders(headerMap: MutableMap) { + synchronized(_defaultHeaders) { + for (toApply in _defaultHeaders) { + if (!headerMap.containsKey(toApply.key)) { + headerMap[toApply.key] = toApply.value + } + } + } + } + + private fun sanitizeResponseHeaders( + headers: Map>?, + onlyWhitelisted: Boolean = false + ): Map> { + val result = mutableMapOf>() + if (onlyWhitelisted) { + headers?.forEach { (header, values) -> + val lowerCaseHeader = header.lowercase() + if (WHITELISTED_RESPONSE_HEADERS.contains(lowerCaseHeader)) { + result[lowerCaseHeader] = values + } + } + } else { + headers?.forEach { (header, values) -> + val lowerCaseHeader = header.lowercase() + if (lowerCaseHeader == "set-cookie" && + !values.any { it.lowercase().contains("httponly") } + ) { + result[lowerCaseHeader] = values + } else { + result[lowerCaseHeader] = values + } + } + } + return result + } + + private fun logRequest( + method: String, + url: String, + headers: Map = HashMap(), + body: String? + ) { + Logger.v(TAG) { + val sb = StringBuilder() + sb.appendLine("HTTP request (libcurl)") + sb.appendLine("$method $url") + for (pair in headers) { + sb.appendLine("${pair.key}: ${pair.value}") + } + if (body != null) { + sb.appendLine() + sb.appendLine(body) + } + sb.toString() + } + } + + fun logExceptions(handle: () -> T): T { + try { + return handle() + } catch (ex: Exception) { + Logger.e("Plugin[${_package._config.name}]", ex.message, ex) + throw ex + } + } + + private fun catchHttp(handle: () -> IBridgeHttpResponse): IBridgeHttpResponse { + return try { + handle() + } catch (ex: SocketTimeoutException) { + BridgeHttpStringResponse("", 408, null) + } + } + } + + data class RequestDescriptor( + val method: String, + val url: String, + val headers: MutableMap, + val body: String? = null, + val contentType: String? = null, + val respType: ReturnType = ReturnType.STRING + ) + + private fun catchHttp(handle: () -> BridgeHttpStringResponse): BridgeHttpStringResponse { + return try { + handle() + } catch (ex: SocketTimeoutException) { + BridgeHttpStringResponse("", 408, null) + } + } + + enum class ReturnType(val value: Int) { + STRING(0), + BYTES(1); + } + + companion object { + private const val TAG = "PackageHttpImp" + private val WHITELISTED_RESPONSE_HEADERS = listOf( + "content-type", + "date", + "content-length", + "last-modified", + "etag", + "cache-control", + "content-encoding", + "content-disposition", + "connection" + ) + } +} diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 61a902bc..c18889ef 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -20,6 +20,7 @@ import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.work.* +import com.curlbind.Libcurl import com.futo.platformplayer.* import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs.Action @@ -382,6 +383,16 @@ class StateApp { Logger.i(TAG, "MainApp Starting"); initializeFiles(true); + _scope?.launch(Dispatchers.IO) { + try { + val caFile = AppCaUpdater.ensureCaBundle(context) + Libcurl.setDefaultCAPath(caFile.absolutePath) + } catch (t: Throwable) { + val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem") + if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath) + } + } + if(Settings.instance.other.polycentricLocalCache) { Logger.i(TAG, "Initialize Polycentric Disk Cache") _cacheDirectory?.let { ApiMethods.initCache(it) }; diff --git a/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate-jni.so b/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate-jni.so new file mode 100755 index 00000000..cc0c132f Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate-jni.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate.so b/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate.so new file mode 100755 index 00000000..33c137b7 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libcurl-impersonate.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate-jni.so b/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate-jni.so new file mode 100755 index 00000000..809a6115 Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate-jni.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate.so b/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate.so new file mode 100755 index 00000000..ce0c9aeb Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libcurl-impersonate.so differ diff --git a/app/src/main/jniLibs/x86/libcurl-impersonate-jni.so b/app/src/main/jniLibs/x86/libcurl-impersonate-jni.so new file mode 100755 index 00000000..c4243995 Binary files /dev/null and b/app/src/main/jniLibs/x86/libcurl-impersonate-jni.so differ diff --git a/app/src/main/jniLibs/x86/libcurl-impersonate.so b/app/src/main/jniLibs/x86/libcurl-impersonate.so new file mode 100755 index 00000000..bb29b738 Binary files /dev/null and b/app/src/main/jniLibs/x86/libcurl-impersonate.so differ diff --git a/app/src/main/jniLibs/x86_64/libcurl-impersonate-jni.so b/app/src/main/jniLibs/x86_64/libcurl-impersonate-jni.so new file mode 100755 index 00000000..c41a9fd7 Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libcurl-impersonate-jni.so differ diff --git a/app/src/main/jniLibs/x86_64/libcurl-impersonate.so b/app/src/main/jniLibs/x86_64/libcurl-impersonate.so new file mode 100755 index 00000000..0e42e82b Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libcurl-impersonate.so differ diff --git a/app/src/stable/assets/sources/kick b/app/src/stable/assets/sources/kick index 4ff0b027..9b3c7ea2 160000 --- a/app/src/stable/assets/sources/kick +++ b/app/src/stable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 4ff0b02700fb5d52fe5bf4cf9bb379d21f6b6853 +Subproject commit 9b3c7ea213c93a88280cd302838bec2fd322f833 diff --git a/app/src/unstable/assets/sources/kick b/app/src/unstable/assets/sources/kick index 4ff0b027..9b3c7ea2 160000 --- a/app/src/unstable/assets/sources/kick +++ b/app/src/unstable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 4ff0b02700fb5d52fe5bf4cf9bb379d21f6b6853 +Subproject commit 9b3c7ea213c93a88280cd302838bec2fd322f833