Implemented httpimp.

This commit is contained in:
Koen J
2025-11-13 14:49:05 +01:00
parent aac19aef86
commit 5355602577
18 changed files with 1069 additions and 2 deletions
+4
View File
@@ -1,2 +1,6 @@
aar/* filter=lfs diff=lfs merge=lfs -text aar/* filter=lfs diff=lfs merge=lfs -text
app/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
+1
View File
@@ -146,6 +146,7 @@ android {
} }
sourceSets { sourceSets {
main { main {
jniLibs.srcDirs = ['src/main/jniLibs']
assets { assets {
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets' srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
} }
@@ -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()
}
}
@@ -33,6 +33,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStateAtLeast import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.curlbind.Libcurl
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.RootInsetsController import com.futo.platformplayer.RootInsetsController
@@ -275,6 +276,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
@UnstableApi @UnstableApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Logger.w(TAG, "MainActivity Starting [$mainId]"); Logger.w(TAG, "MainActivity Starting [$mainId]");
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId); StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
StateApp.instance.mainAppStarting(this); StateApp.instance.mainAppStarting(this);
@@ -36,6 +36,7 @@ import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.PackageBridge import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.engine.packages.PackageDOMParser import com.futo.platformplayer.engine.packages.PackageDOMParser
import com.futo.platformplayer.engine.packages.PackageHttp 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.PackageJSDOM
import com.futo.platformplayer.engine.packages.PackageUtilities import com.futo.platformplayer.engine.packages.PackageUtilities
import com.futo.platformplayer.engine.packages.V8Package import com.futo.platformplayer.engine.packages.V8Package
@@ -383,6 +384,7 @@ class V8Plugin {
return when(packageName) { return when(packageName) {
"DOMParser" -> PackageDOMParser(this) "DOMParser" -> PackageDOMParser(this)
"Http" -> PackageHttp(this, config) "Http" -> PackageHttp(this, config)
"HttpImp" -> PackageHttpImp(this, config)
"Utilities" -> PackageUtilities(this, config) "Utilities" -> PackageUtilities(this, config)
"JSDOM" -> PackageJSDOM(this, config) "JSDOM" -> PackageJSDOM(this, config)
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}"); else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
@@ -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<String, String> = 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<String, List<String>>
)
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<String>(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<String>): Map<String, List<String>> {
val map = linkedMapOf<String, MutableList<String>>()
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
}
@@ -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<String, PackageHttpClient>()
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 <T, R> autoParallelPool(
data: List<T>,
parallelism: Int,
handle: (T) -> R
): List<Pair<R?, Throwable?>> {
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<ForkJoinTask<Pair<R?, Throwable?>>>()
for (item in data) {
resultTasks.add(
pool.submit<Pair<R?, Throwable?>> {
try {
Pair(handle(item), null)
} catch (ex: Throwable) {
Pair<R?, Throwable?>(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<String, String> = 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<String, String> = 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<String, String> = 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<String, String> = 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 <T> 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<String, List<String>>?
}
@kotlinx.serialization.Serializable
class BridgeHttpStringResponse(
override val url: String,
override val code: Int,
val body: String?,
override val headers: Map<String, List<String>>? = 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<String, List<String>>? = 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<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()
) : V8BindObject() {
@Transient
private val _reqs = existingRequests
@V8Function
fun request(
method: String,
url: String,
headers: MutableMap<String, String> = 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<String, String> = HashMap(),
useAuth: Boolean = false
): BatchBuilder {
return clientRequestWithBody(_package.getDefaultClient(useAuth).clientId(), method, url, body, headers)
}
@V8Function
fun GET(
url: String,
headers: MutableMap<String, String> = HashMap(),
useAuth: Boolean = false
): BatchBuilder =
clientGET(_package.getDefaultClient(useAuth).clientId(), url, headers)
@V8Function
fun POST(
url: String,
body: String,
headers: MutableMap<String, String> = 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<String, String> = 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<String, String> = 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<String, String> = HashMap()
): BatchBuilder =
clientRequest(clientId, "GET", url, headers)
@V8Function
fun clientPOST(
clientId: String?,
url: String,
body: String,
headers: MutableMap<String, String> = HashMap()
): BatchBuilder =
clientRequestWithBody(clientId, "POST", url, body, headers)
@V8Function
fun execute(): List<IBridgeHttpResponse?> {
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<String, String>()
@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<String, String>) {
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<String, String> = HashMap(),
useBytes: Boolean = false
): IBridgeHttpResponse =
requestInternal(method, url, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
fun requestInternal(
method: String,
url: String,
headers: MutableMap<String, String> = 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<String, String> = 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<String, String> = 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<String, String> = HashMap(),
useBytes: Boolean = false
): IBridgeHttpResponse =
GETInternal(url, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
fun GETInternal(
url: String,
headers: MutableMap<String, String> = 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<String, String> = 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<String, String> = 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<String, String> = 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<String, String>,
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<String, String>) {
synchronized(_defaultHeaders) {
for (toApply in _defaultHeaders) {
if (!headerMap.containsKey(toApply.key)) {
headerMap[toApply.key] = toApply.value
}
}
}
}
private fun sanitizeResponseHeaders(
headers: Map<String, List<String>>?,
onlyWhitelisted: Boolean = false
): Map<String, List<String>> {
val result = mutableMapOf<String, List<String>>()
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<String, String> = 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 <T> 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<String, String>,
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"
)
}
}
@@ -20,6 +20,7 @@ import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.work.* import androidx.work.*
import com.curlbind.Libcurl
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs.Action import com.futo.platformplayer.UIDialogs.Action
@@ -382,6 +383,16 @@ class StateApp {
Logger.i(TAG, "MainApp Starting"); Logger.i(TAG, "MainApp Starting");
initializeFiles(true); 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) { if(Settings.instance.other.polycentricLocalCache) {
Logger.i(TAG, "Initialize Polycentric Disk Cache") Logger.i(TAG, "Initialize Polycentric Disk Cache")
_cacheDirectory?.let { ApiMethods.initCache(it) }; _cacheDirectory?.let { ApiMethods.initCache(it) };
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.