Files
maidong-ktv/app/src/main/java/com/local/ktv/KtvJsBridge.kt
T
zijunchiang 1687f87eca V1.4.7: 新增原生取址兜底 —— 修复 WebView 不可用的老盒子无法点歌
证据(用户导出的 1.4.6 远程日志):
- 两条失败记录只有 Kotlin 侧那一行
  「点歌编号=... 详情=无法生成下载地址」,
  而 JS ...(本版新加的 console 桥)、RAW-ERR、NET-ERR、点歌失败 一条都没有。
- 即 JS 侧从未执行 —— 隐藏 WebView 在这台盒子上从未就绪
  (每条失败前正好有约 15s 的「等桥就绪」超时,时间吻合)。
- 于是 SongApiClient 只能回退 DB 的 cloud_url,取不到就报「无法生成下载地址」。

根因: 取址完全依赖 WebView 里的 ktv_api.js;GMUI2.0/Android 5.1.1 上
WebView 不可用(无法导出 window.KtvApi),桥永远 not ready。

修复:
- 新增 SongUrlResolver: 用 Kotlin 复刻 ktv_api.js 的协议
  (GET <host>/i.php 取 token → GET <host>/music/do.php 取地址),
  镜像多源排序(失败源沉底)与 isDemoUrl 校验(wb66/demo/纯IP/r2.dev/ts编号不一致)。
- 走与 JS 完全相同的裸 Socket「干净请求」通道(KtvJsBridge.rawCleanGet),
  请求特征一致故风控表现一致,且不依赖 WebView 是否就绪。
- SongApiClient.getSongDownloadUrl 三处回退点改为:
  桥未就绪 / JS 未取到地址 / 主线程调用 → 先走原生解析,再回退 DB。
- 补齐桥的生命周期可观测性: WebView 初始化失败、KtvApi 未导出、
  15s 未就绪 均写入远程日志(以前只在 logcat,这就是本轮一开始没有线索的原因)。

已用 App 的真实身份( mac=800b510bf4fd / sn=2d7bb4a7d7f7 / VER=2.0 )
从本机复现协议: i.php 200 → do.php code=200 且返回
  http://txog.ktvsky.com/.../480p/7303235.ts (与点歌编号一致),
证明参数顺序与签名算法正确。

版本 1.4.6(26) 到 1.4.7(27);签名证书不变。ktv_api.js 未改动,KTV_BRIDGE_API 仍为 8。
2026-09-26 17:08:38 +08:00

607 lines
31 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.local.ktv
import android.content.Context
import android.net.Uri
import android.net.http.SslError
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebView
import android.webkit.WebViewClient
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.Socket
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.Locale
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import java.security.KeyFactory
import java.security.spec.X509EncodedKeySpec
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.crypto.Cipher
/**
* 在隐藏 WebView 中运行可热更新的歌曲接口脚本。
*
* JS 决定接口地址、参数、签名和响应解析;Android 只提供 HTTP 与 RSA 这两个
* WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitea 上的 ktv_api.js。
*/
class KtvJsBridge(context: Context) {
companion object {
private const val TAG = "KtvJsBridge"
private const val JS_FILE = "mobile/ktv_api.js"
private const val CACHE_FILE = "ktv_api_cache.js"
// 热更新脚本地址(Gitea 主仓库; raw 规范路径为 /raw/branch/<分支>/<文件>)
private const val REMOTE_JS_URL =
"https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js"
private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
private const val JS_REQUEST_TIMEOUT_MS = 180_000
// 歌曲源(w.w345.my 与 e.ac19.cn 同后台)按 device 识别; 随机/变化的 device 会被风控。
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
private val DEVICE_MAC: String get() = DeviceMac.effective()
internal const val DEVICE_SN = "2d7bb4a7d7f7"
/**
* 断网重连后重置歌曲源会话。
*
* 源站按「出口 IP + device」识别,试用窗口也记在出口 IP 上(见 Eac19WindowGuard)。
* 断网/重连(尤其换了出口 IP)后旧 token 已失效,继续复用会表现为
* **「取址看起来成功、实际下载 403/失败」**。这里把整个 API 实例与初始化 Promise
* 都清掉:下一次取址会重新 new KtvApi(回到默认源顺序)并重新登录拿新 token。
*/
private const val RESET_SESSION_JS = """(function(){
try {
window.api = null;
window._apiInitPromise = null;
} catch (e) {}
return 'ok';
})()"""
}
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private val callbacks = ConcurrentHashMap<String, (String?) -> Unit>()
@Volatile private var webView: WebView? = null
@Volatile private var ready = false
@Volatile private var destroyed = false
private var loadedFromCache = false
private var assetRetryAttempted = false
/** 最近一次点歌实际命中的歌曲源 host(由 JS reportSongResult 更新, 供远程日志)。 */
@Volatile
var lastSongSourceHost: String = ""
private set
/** 必须最终在主线程创建 WebView;可从任意线程调用。 */
fun init(callback: (Boolean) -> Unit) {
mainHandler.post {
if (destroyed) {
callback(false)
return@post
}
try {
val cached = File(appContext.filesDir, CACHE_FILE)
val cachedScript = cached.takeIf(::isValidScript)?.readText(Charsets.UTF_8)
loadedFromCache = cachedScript != null
createWebView(cachedScript ?: readAssetScript(), callback)
updateCache(cached)
} catch (error: Throwable) {
Log.e(TAG, "WebView init failed", error)
MiliLog.error("JS-BRIDGE WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}")
callback(false)
}
}
}
private fun createWebView(script: String, callback: (Boolean) -> Unit) {
webView?.destroy()
ready = false
webView = WebView(appContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(NativeBridge(), "android")
webViewClient = object : WebViewClient() {
/**
* 老系统(API < 24)的 WebView 不读 network_security_config,
* 系统信任库也没有 Let's Encrypt 新根 → 访问 mws.cherryonline.cn 会直接握手失败。
* 这里**不做盲目放行**:用 [TlsCompat] 增强后的信任库对同一主机独立校验一次,
* 只有校验通过(证书链合法且主机名匹配)才继续。
*/
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?,
) {
val host = runCatching { Uri.parse(view?.url)?.host }.getOrNull()
val onlyUntrusted = error?.primaryError == SslError.SSL_UNTRUSTED
if (onlyUntrusted && host != null && TlsCompat.isHostTrustedByOurStore(host)) {
Log.w(TAG, "legacy TLS: $host 已用增强信任库复核通过,放行 WebView")
handler?.proceed()
} else {
Log.e(TAG, "SSL error NOT overridden: host=$host primary=${error?.primaryError}")
handler?.cancel()
}
}
override fun onPageFinished(view: WebView, url: String) {
view.evaluateJavascript("typeof window.KtvApi === 'function'") { result ->
if (result == "true") {
ready = true
Log.i(TAG, "JS runtime ready (${if (loadedFromCache) "cache" else "asset"})")
MiliLog.info("JS-BRIDGE ready (${if (loadedFromCache) "cache" else "asset"})")
callback(true)
} else if (loadedFromCache && !assetRetryAttempted) {
Log.w(TAG, "Cached JS failed to load; falling back to packaged asset")
assetRetryAttempted = true
loadedFromCache = false
createWebView(readAssetScript(), callback)
} else {
Log.e(TAG, "KtvApi was not exported by JS")
// 老电视盒子上 WebView 不可用/被裁剪时常走这里:桥永远不就绪,
// 取址只能回退 DB → 「无法生成下载地址」。必须能远程看到。
MiliLog.error("JS-BRIDGE KtvApi 未导出,WebView 不可用")
callback(false)
}
}
}
}
loadDataWithBaseURL("https://localhost/", wrapHtml(script), "text/html", "UTF-8", null)
}
}
/** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */
fun isReady(): Boolean = ready && !destroyed
/**
* 供**原生兜底取址解析器**([SongUrlResolver])复用的「干净请求」通道。
*
* 与 JS 的 `android.httpGet` 走的是**完全同一条**裸 Socket 路径(只发 Host + Accept,
* 不带 UA/Connection/Accept-Encoding),因此请求特征与风控表现一致;
* 且**不依赖 WebView 是否就绪** —— 正是 WebView 不可用的老设备所需要的。
*/
fun rawCleanGet(url: String, timeoutMs: Int = 8_000): String {
val headers = JSONObject().put("Accept", "*/*").toString()
return NativeBridge().request("GET", url, null, headers, timeoutMs)
}
fun isDestroyed(): Boolean = destroyed
/**
* 重置 JS 侧歌曲源会话(断网恢复 / 下载重试时调用)。
*
* 置空 `window.api` 与 `window._apiInitPromise`,下一次取址会重新构造 API 实例
* (恢复默认源顺序)并重新登录,避免继续使用失效 token。
*/
fun resetApiSession() {
val view = webView
if (!ready || destroyed || view == null) return
mainHandler.post {
runCatching {
view.evaluateJavascript(RESET_SESSION_JS, null)
Log.i(TAG, "reset source api session")
}.onFailure { Log.w(TAG, "reset source api session failed", it) }
}
}
fun getSongUrl(
musicNo: String,
resolution: String,
h265: Boolean,
callback: (String?) -> Unit,
) {
if (!ready || destroyed) {
callback(null)
return
}
val requestId = UUID.randomUUID().toString()
callbacks[requestId] = callback
val script = """
(function() {
var requestId = ${JSONObject.quote(requestId)};
var completed = false;
function finish(url) {
if (completed) return;
completed = true;
clearTimeout(timer);
android.onUrl(requestId, url || '');
}
var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS);
Promise.resolve().then(async function() {
if (!window.api) {
// 歌曲源按 device 识别:随机/每次更换的 device 会被判"异常行为"。
// 使用本机当前网卡 mac + 抓包注册的 sn(会话内保持稳定),并保持版本号一致。
if (window.KtvApiConfig) {
window.KtvApiConfig.VER = '2.0';
window.KtvApiConfig.VN = '4.1.3.03281430';
}
window.api = new window.KtvApi({mac: ${JSONObject.quote(DEVICE_MAC)}, sn: ${JSONObject.quote(DEVICE_SN)}, maxDeviceRetries: 0, debug: true});
var _regen = window.api.regenerateDevice;
window.api.regenerateDevice = function() {
_regen.call(window.api);
window.api.mac = ${JSONObject.quote(DEVICE_MAC)};
window.api.sn = ${JSONObject.quote(DEVICE_SN)};
};
}
if (!window._apiInitPromise) {
window._apiInitPromise = window.api.init().then(function(ok) {
if (!ok) throw new Error('API initialization failed');
return true;
}).catch(function(error) {
window._apiInitPromise = null;
throw error;
});
}
await window._apiInitPromise;
return window.api.getSongUrl(
${JSONObject.quote(musicNo)}, ${JSONObject.quote(resolution)}, ${if (h265) "true" else "false"}
);
}).then(finish).catch(function(error) {
android.log('getSongUrl failed: ' + (error && error.message ? error.message : error));
finish('');
});
})();
""".trimIndent()
mainHandler.post {
if (!ready) complete(requestId, null)
else webView?.evaluateJavascript(script, null) ?: complete(requestId, null)
}
}
// JS isDemoUrl 已做第一层正则过滤; 这里再做域名白名单兜底, 防止 demo URL 绕过
private val trustedHostSuffixes = listOf(
"ktvsky.com", "origjoy.com", "cherryonline.cn", "ac16.vip",
"ktvdaren.com", "j-make.cn",
)
// 已知 demo 域名模式 (Cloudflare R2 测试桶、IP 地址等)
private val demoHostPatterns = listOf(
Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), // 纯 IP
Regex("^pub-[a-z0-9]+\\.r2\\.dev$"), // R2 demo bucket
)
private fun isTrustedDownloadUrl(url: String?): Boolean {
if (url == null) return false
if (!url.startsWith("http://") && !url.startsWith("https://")) return false
val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return false
if (demoHostPatterns.any { it.matches(host) }) {
Log.w(TAG, "Rejected demo URL host: $host")
return false
}
val trusted = trustedHostSuffixes.any { host.endsWith(it) }
if (!trusted) Log.w(TAG, "Untrusted URL host: $host")
return trusted
}
private fun complete(requestId: String, url: String?) {
// 先屏蔽/替换广告视频,再做域名白名单校验。
callbacks.remove(requestId)?.invoke(AdVideoFilter.resolve(url)?.takeIf { isTrustedDownloadUrl(it) })
}
private fun readAssetScript(): String =
appContext.assets.open(JS_FILE).bufferedReader(Charsets.UTF_8).use { it.readText() }
private fun isValidScript(file: File): Boolean =
runCatching {
file.isFile && file.length() in 1_001..500_000 &&
file.readText(Charsets.UTF_8).let {
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 8")
}
}.getOrDefault(false)
/** 下载成功后原子替换缓存;新脚本在下次启动时生效。 */
private fun updateCache(cacheFile: File) {
Thread({
val tempFile = File(cacheFile.parentFile, "${cacheFile.name}.tmp")
try {
val connection = (URL(REMOTE_JS_URL).openConnection() as HttpURLConnection).apply {
connectTimeout = 10_000
readTimeout = 15_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", "KTV-Updater/2.0")
}
try {
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IllegalStateException("HTTP ${connection.responseCode}")
}
val declaredLength = connection.contentLengthCompat()
require(declaredLength < 0 || declaredLength <= MAX_RESPONSE_BYTES) { "JS file is too large" }
val bytes = connection.inputStream.use { it.readBytes() }
require(bytes.size <= MAX_RESPONSE_BYTES) { "JS file is too large" }
val script = String(bytes, StandardCharsets.UTF_8)
require(
script.length > 1000 && script.contains("KtvApi") &&
script.contains("KTV_BRIDGE_API: 8"),
) { "Invalid or incompatible JS file" }
tempFile.writeText(script, Charsets.UTF_8)
if (!tempFile.renameTo(cacheFile)) {
cacheFile.writeText(script, Charsets.UTF_8)
tempFile.delete()
}
Log.i(TAG, "JS cache updated from Gitea (${script.length} chars)")
// 启动后刚拉到的新版本直接生效;若已有请求在执行,则留到下次启动。
mainHandler.post {
if (ready && callbacks.isEmpty() && !destroyed) {
loadedFromCache = true
createWebView(script) { ok -> Log.i(TAG, "Hot-reloaded JS: $ok") }
}
}
} finally {
connection.disconnect()
}
} catch (error: Throwable) {
tempFile.delete()
Log.w(TAG, "JS update failed: ${error.message}")
}
}, "ktv-js-updater").start()
}
private fun wrapHtml(script: String): String {
val escaped = script.replace("</script", "<\\/script", ignoreCase = true)
// console.log 桥接:ktv_api.js 的 _log()/_log 细节(Try source / Token FAIL / do.php code=
// 等)原本只进 WebView 控制台,远程日志里完全看不到,点歌失败无从定位。
// 这里把它转发到 android.log → MiliLog,并保留原始 console 行为。
val consoleBridge = """
(function(){
try {
var orig = console.log;
console.log = function(){
try { android.log(Array.prototype.map.call(arguments, String).join(' ')); } catch(e) {}
if (orig) { try { orig.apply(console, arguments); } catch(e) {} }
};
} catch(e) {}
})();
""".trimIndent()
return "<!doctype html><html><head><meta charset=\"utf-8\"></head><body><script>$consoleBridge</script>" +
"<script>$escaped</script></body></html>"
}
inner class NativeBridge {
@JavascriptInterface
fun log(message: String) {
Log.d(TAG, message)
// 把 JS 侧日志接进远程日志:歌曲源解析(token/do.php/多源排序)的失败原因
// 以前只走 console.log,现场完全看不到。加上前缀便于检索与过滤。
runCatching { MiliLog.info("JS ${message.take(180)}") }
}
@JavascriptInterface
fun onUrl(requestId: String, url: String) {
Log.d(TAG, "URL result ${requestId.take(8)}: ${url.take(100)}")
complete(requestId, url)
}
@JavascriptInterface
fun rsaEncryptPkcs1(publicKeyBase64: String, plaintext: String): String =
runCatching {
val keyBytes = Base64.decode(publicKeyBase64.replace("\\s".toRegex(), ""), Base64.DEFAULT)
val publicKey = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(keyBytes))
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
Base64.encodeToString(cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
}.onFailure { Log.e(TAG, "RSA encryption failed", it) }.getOrDefault("")
@JavascriptInterface
fun httpGet(url: String, headersJson: String, timeoutMs: Int): String =
request("GET", url, null, headersJson, timeoutMs)
@JavascriptInterface
fun httpPost(url: String, body: String, headersJson: String, timeoutMs: Int): String =
request("POST", url, body, headersJson, timeoutMs)
// 任务3: JS 点歌结果上报(成功/失败 + 实际命中的歌曲源 host)。
// 由 Kotlin 调用方(SongOkDownloadManager)补充标题后写入远程日志,
// 同时成功即表示窗口/网络恢复 → 复位 e.ac19 窗口守卫提示状态。
@JavascriptInterface
fun reportSongResult(status: String, host: String, musicno: String, detail: String) {
val ok = status == "ok"
if (host.isNotBlank()) lastSongSourceHost = host
if (ok) Eac19WindowGuard.noteUrlOk()
// 具体含标题的日志由 SongOkDownloadManager 在拿到 URL 后统一写,
// 这里只留简短来源记录, 避免重复刷屏。
if (!ok) {
MiliLog.error("点歌失败 编号=$musicno 请求源=$host 详情=${detail.take(160)}")
}
}
/** 供 [rawCleanGet] 与 [NativeBridge.httpGet]/[httpPost] 共用的请求入口。 */
internal fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
// 歌曲源(如 w.w345.my)的 do.php 风控只放行"干净"请求(原版仅 Host+Accept;
// 带任何 UA/Connection/Accept-Encoding 头都会被拒)。裸 Socket 只发 Host+JS 头。
// 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection
// 补发第二次(限流时补发只会双倍放大请求量,加速被源限流)。
val raw = rawRequest(method, url, body, headersJson, timeoutMs)
val status = runCatching { JSONObject(raw).optInt("status") }.getOrDefault(0)
if (status > 0) {
// 远程日志: 风控/服务端错误(403/5xx)实时上报, 便于排查间歇性点歌失败。
if (status == 403 || status >= 500) {
MiliLog.error("HTTP-$status $method $url")
}
// 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为",
// 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。
if (status == 403 && url.contains("/music/do.php")) {
Eac19WindowGuard.noteRiskBlocked()
}
return raw
}
// 仅当裸 Socket 连接失败(status=0)时回退 HttpURLConnection。
Log.w(TAG, "raw socket connect failed, falling back to HttpURLConnection: $url")
val fallback = httpRequest(method, url, body, headersJson, timeoutMs)
val fallbackStatus = runCatching { JSONObject(fallback).optInt("status") }.getOrDefault(0)
if (fallbackStatus == 0) {
// 两个通道都失败 → 网络不可达/解析失败, 实时上报。
val err = runCatching { JSONObject(fallback).optString("error") }.getOrDefault("")
MiliLog.error("NET-ERR $method $url $err")
}
return fallback
}
/** HttpURLConnection 通道:JS 未指定 UA/编码/连接时改中性值,避免默认头触发风控。 */
private fun httpRequest(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
var connection: HttpURLConnection? = null
return try {
require(url.startsWith("http://") || url.startsWith("https://")) { "Unsupported URL" }
connection = URL(url).openConnection() as HttpURLConnection
connection.requestMethod = method
connection.connectTimeout = timeoutMs.coerceIn(1_000, 30_000)
connection.readTimeout = timeoutMs.coerceIn(1_000, 30_000)
connection.instanceFollowRedirects = true
val headers = JSONObject(headersJson.ifBlank { "{}" })
headers.keys().forEach { name -> connection.setRequestProperty(name, headers.optString(name)) }
// 歌曲源的 /music/do.php 风控只放行"干净"的请求(抓包只发 Host+Accept):
// 空 UA、Dalvik、okhttp、Chrome 的 UA 都会返回"检测到异常行为",Go UA 可放行。
// HttpURLConnection 默认会补 Dalvik UA、Accept-Encoding: gzip、Connection: Keep-Alive,
// 这里在 JS 未显式指定时全部改成中性值,尽量贴近原版请求。
if (!headers.has("User-Agent")) connection.setRequestProperty("User-Agent", "Go-http-client/1.1")
if (!headers.has("Accept-Encoding")) connection.setRequestProperty("Accept-Encoding", "identity")
if (!headers.has("Connection")) connection.setRequestProperty("Connection", "close")
if (body != null) {
connection.doOutput = true
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
}
val status = connection.responseCode
val stream = if (status >= 400) connection.errorStream else connection.inputStream
val responseBody = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
JSONObject().put("status", status).put("body", responseBody).toString()
} catch (error: Throwable) {
Log.w(TAG, "$method request failed: ${error.message}")
JSONObject().put("status", 0).put("body", "").put("error", error.message.orEmpty()).toString()
} finally {
connection?.disconnect()
}
}
/** 裸 Socket 通道:只发 Host + JS 指定的请求头,不再补任何默认头(UA/Connection/Accept-Encoding)。 */
private fun rawRequest(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
var socket: Socket? = null
return try {
require(url.startsWith("http://") || url.startsWith("https://")) { "Unsupported URL" }
val isHttps = url.startsWith("https://")
val uri = URL(url).toURI()
val port = if (uri.port > 0) uri.port else if (isHttps) 443 else 80
val connectTimeout = timeoutMs.coerceIn(1_000, 30_000)
socket = if (isHttps) {
// 用 TlsCompat 的工厂(系统信任 ∪ 内置根证书):裸 socket 走的是
// SSLSocketFactory.getDefault(),不受 HttpsURLConnection 的默认工厂影响,
// network_security_config 也管不到它 —— 老设备上会因缺 ISRG 新根握手失败。
(TlsCompat.sslSocketFactory().createSocket() as SSLSocket).apply {
connect(java.net.InetSocketAddress(uri.host, port), connectTimeout)
startHandshake()
}
} else {
// ⚠️ 必须显式给连接超时:原写法 `Socket(host, port)` 的连接阶段**不受**
// soTimeout 约束,遇到路由黑洞会按系统 TCP 超时(约 2 分钟)干等,
// 多个源叠加会把 JS 的 3 分钟预算耗光,表现为「无法生成下载地址」。
Socket().apply {
connect(java.net.InetSocketAddress(uri.host, port), connectTimeout)
}
}
socket.soTimeout = timeoutMs.coerceIn(1_000, 30_000)
val path = if (uri.rawQuery != null) "${uri.rawPath}?${uri.rawQuery}" else uri.rawPath
val requestText = StringBuilder()
requestText.append("$method $path HTTP/1.1\r\n")
requestText.append("Host: ${uri.host}\r\n")
val headers = JSONObject(headersJson.ifBlank { "{}" })
headers.keys().forEach { name -> requestText.append("$name: ${headers.optString(name)}\r\n") }
val bodyBytes = body?.toByteArray(Charsets.UTF_8)
if (bodyBytes != null) requestText.append("Content-Length: ${bodyBytes.size}\r\n")
requestText.append("\r\n")
val out = socket.getOutputStream()
out.write(requestText.toString().toByteArray(Charsets.UTF_8))
if (bodyBytes != null) out.write(bodyBytes)
out.flush()
val input = BufferedInputStream(socket.getInputStream())
val statusLine = readAsciiLine(input)
val status = statusLine?.split(" ")?.getOrNull(1)?.toIntOrNull() ?: 0
var contentLength = -1L
var chunked = false
while (true) {
val line = readAsciiLine(input) ?: break
if (line.isEmpty()) break
val idx = line.indexOf(':')
if (idx > 0) {
val name = line.substring(0, idx).trim().lowercase(Locale.ROOT)
val value = line.substring(idx + 1).trim()
if (name == "content-length") contentLength = value.toLongOrNull() ?: -1L
if (name == "transfer-encoding" && value.contains("chunked")) chunked = true
}
}
val responseBody = when {
chunked -> readChunkedBody(input)
contentLength >= 0 -> String(readExact(input, contentLength), Charsets.UTF_8)
else -> input.readBytes().toString(Charsets.UTF_8)
}
JSONObject().put("status", status).put("body", responseBody).toString()
} catch (error: Throwable) {
Log.w(TAG, "raw request failed: ${error.message}")
// 裸 Socket 失败原因以前只在 logcat:点歌失败时远程日志里什么都看不到。
MiliLog.warn(
"RAW-ERR $method ${runCatching { java.net.URI(url).host }.getOrNull() ?: url} " +
"${error.javaClass.simpleName}: ${error.message}",
)
JSONObject().put("status", 0).put("body", "").put("error", error.message.orEmpty()).toString()
} finally {
try { socket?.close() } catch (ignored: Exception) {}
}
}
private fun readAsciiLine(input: InputStream): String? {
val sb = StringBuilder()
while (true) {
val b = input.read()
if (b < 0) return if (sb.isEmpty()) null else sb.toString()
if (b == '\n'.code) {
if (sb.isNotEmpty() && sb[sb.length - 1] == '\r') sb.setLength(sb.length - 1)
return sb.toString()
}
sb.append(b.toChar())
}
}
private fun readExact(input: InputStream, length: Long): ByteArray {
val result = ByteArray(length.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
var off = 0
while (off < result.size) {
val n = input.read(result, off, result.size - off)
if (n < 0) break
off += n
}
return if (off == result.size) result else result.copyOf(off)
}
private fun readChunkedBody(input: InputStream): String {
val out = ByteArrayOutputStream()
while (true) {
val sizeLine = readAsciiLine(input) ?: break
val size = sizeLine.substringBefore(';').trim().toIntOrNull(16) ?: 0
if (size <= 0) break
out.write(readExact(input, size.toLong()))
readAsciiLine(input) // 吃掉 chunk 尾部 CRLF
}
// ByteArrayOutputStream.toString(Charset) 是 API 33+;低版本用 String(bytes, charset)。
return String(out.toByteArray(), Charsets.UTF_8)
}
}
fun destroy() {
destroyed = true
ready = false
callbacks.keys.toList().forEach { complete(it, null) }
mainHandler.post {
webView?.removeJavascriptInterface("android")
webView?.destroy()
webView = null
}
}
}