麦动KTV: 移除Gitee数据库(改官方Muse同步), 歌曲源切w.w345.my, 远程日志+广告屏蔽
- DatabaseBootstrapper: 删除 Gitee 分片/manifest/blob 下载逻辑, 曲库只走官方 MuseDbSync - build.gradle: 移除 GITEE_DATABASE_* 构建参数 - MainActivity: 重置数据库文案改为官方同步 - ktv_api.js: 歌曲源仅 w.w345.my, 缓存标记 7, 广告/错号资源拦截+重试 - 远程日志: 点歌记录 编号/标题/请求源/状态
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
package com.local.ktv
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.webkit.JavascriptInterface
|
||||
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 中不可靠的底层能力。这样接口变化时只需更新 Gitee 上的 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"
|
||||
private const val REMOTE_JS_URL =
|
||||
"https://gitee.com/zijunchiang/maidong-ktv/raw/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 使用的设备标识(mac + sn)。与用户抓包示例一致,
|
||||
// w.w345.my 与 e.ac19.cn 同后台,按 device 识别; 随机/变化的 device 会被风控。
|
||||
private const val DEVICE_MAC = "ae60e9547958"
|
||||
private const val DEVICE_SN = "2d7bb4a7d7f7"
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/** 必须最终在主线程创建 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)
|
||||
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() {
|
||||
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"})")
|
||||
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")
|
||||
callback(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loadDataWithBaseURL("https://localhost/", wrapHtml(script), "text/html", "UTF-8", null)
|
||||
}
|
||||
}
|
||||
|
||||
/** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */
|
||||
fun isReady(): Boolean = ready && !destroyed
|
||||
|
||||
fun isDestroyed(): Boolean = destroyed
|
||||
|
||||
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: 7")
|
||||
}
|
||||
}.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.contentLengthLong
|
||||
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: 7"),
|
||||
) { "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 Gitee (${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)
|
||||
return "<!doctype html><html><head><meta charset=\"utf-8\"></head><body><script>$escaped</script></body></html>"
|
||||
}
|
||||
|
||||
inner class NativeBridge {
|
||||
@JavascriptInterface
|
||||
fun log(message: String) = Log.d(TAG, message)
|
||||
|
||||
@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)
|
||||
|
||||
private 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
|
||||
socket = if (isHttps) {
|
||||
(SSLSocketFactory.getDefault().createSocket(uri.host, port) as SSLSocket).apply { startHandshake() }
|
||||
} else {
|
||||
Socket(uri.host, port)
|
||||
}
|
||||
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}")
|
||||
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
|
||||
}
|
||||
return out.toString(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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user