diff --git a/AGENTS.md b/AGENTS.md index 8ab607c..02d8e0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.4.6"` / `versionCode 26`(18~21 被本机临时诊断包占用过) +- `app/build.gradle`:`versionName "1.4.7"` / `versionCode 27`(18~21 被本机临时诊断包占用过) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 diff --git a/app/build.gradle b/app/build.gradle index 645448c..5b1d094 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 26 - versionName "1.4.6" + versionCode 27 + versionName "1.4.7" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index 6dbdc21..8c5a0a7 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -49,7 +49,7 @@ class KtvJsBridge(context: Context) { // mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac); // sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。 private val DEVICE_MAC: String get() = DeviceMac.effective() - private const val DEVICE_SN = "2d7bb4a7d7f7" + internal const val DEVICE_SN = "2d7bb4a7d7f7" /** * 断网重连后重置歌曲源会话。 @@ -98,6 +98,7 @@ class KtvJsBridge(context: Context) { updateCache(cached) } catch (error: Throwable) { Log.e(TAG, "WebView init failed", error) + MiliLog.error("JS-BRIDGE WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}") callback(false) } } @@ -138,6 +139,7 @@ class KtvJsBridge(context: Context) { 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") @@ -146,6 +148,9 @@ class KtvJsBridge(context: Context) { createWebView(readAssetScript(), callback) } else { Log.e(TAG, "KtvApi was not exported by JS") + // 老电视盒子上 WebView 不可用/被裁剪时常走这里:桥永远不就绪, + // 取址只能回退 DB → 「无法生成下载地址」。必须能远程看到。 + MiliLog.error("JS-BRIDGE KtvApi 未导出,WebView 不可用") callback(false) } } @@ -158,6 +163,18 @@ class KtvJsBridge(context: Context) { /** 桥当前是否可用。热更新重载期间会短暂为 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 /** @@ -396,7 +413,8 @@ class KtvJsBridge(context: Context) { } } - private fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String { + /** 供 [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 diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt index 2792c9f..99ef3bd 100644 --- a/app/src/main/java/com/local/ktv/SongApiClient.kt +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -24,9 +24,13 @@ object SongApiClient { fun init(context: Context) { if (bridge != null) return bridge = KtvJsBridge(context) + // 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致), + // 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。 + SongUrlResolver.install { url -> bridge?.rawCleanGet(url) } bridge?.init { ok -> ready = ok Log.i(TAG, "JS Bridge init: $ok") + if (!ok) MiliLog.error("JS-BRIDGE 初始化返回 false,取址将走原生兜底") } } @@ -47,10 +51,10 @@ object SongApiClient { fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? { // 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。 if (Looper.myLooper() == Looper.getMainLooper()) { - Log.w(TAG, "Synchronous JS request on main thread; using DB fallback") - return fetchFromDb(musicno) + Log.w(TAG, "Synchronous JS request on main thread; using native/DB fallback") + return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) } - val bridge = bridge ?: return fetchFromDb(musicno) + val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) // 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备 // (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。 // 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。 @@ -62,8 +66,11 @@ object SongApiClient { } } if (!bridge.isReady()) { - Log.w(TAG, "Bridge not ready after wait, DB fallback") - return fetchFromDb(musicno) + // ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是 + // 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器。 + Log.w(TAG, "Bridge not ready after wait, native resolver") + MiliLog.warn("JS-BRIDGE 15s 未就绪,改用原生取址通道") + return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) } val result = AtomicReference() val latch = CountDownLatch(1) @@ -74,7 +81,9 @@ object SongApiClient { } catch (_: InterruptedException) { Thread.currentThread().interrupt() } - val url = result.get() ?: fetchFromDb(musicno) + // 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会, + // 避免 JS 侧偶发失败直接把第一首歌判死。 + val url = result.get() ?: resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) if (url != null) { // 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。 Eac19WindowGuard.noteUrlOk() @@ -82,9 +91,17 @@ object SongApiClient { return url } + /** + * 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。 + * 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。 + */ + private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): String? = + runCatching { SongUrlResolver.resolve(musicno, resolution, h265) } + .onFailure { Log.w(TAG, "native resolver failed", it) } + .getOrNull() + /** DB cloud_url fallback — 必须通过 demo 检测才能使用 */ - private fun fetchFromDb(musicno: String): String? { - try { + private fun fetchFromDb(musicno: String): String? { try { val db = MuseDatabase() if (db.open()) { val url = db.getCloudUrl(musicno) diff --git a/app/src/main/java/com/local/ktv/SongUrlResolver.kt b/app/src/main/java/com/local/ktv/SongUrlResolver.kt new file mode 100644 index 0000000..165eed2 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SongUrlResolver.kt @@ -0,0 +1,214 @@ +package com.local.ktv + +import android.util.Log +import org.json.JSONObject +import java.security.MessageDigest +import java.util.Collections + +/** + * 歌曲取址的**原生兜底实现**。 + * + * ## 为什么需要它 + * 取址原本完全依赖隐藏 WebView 里的 `ktv_api.js`。但在一部分老电视盒子/投影上 + * (实测:极米 H1S / GMUI2.0 / Android 5.1.1 / API 22)**WebView 不可用**: + * `KtvJsBridge` 永远 `isReady()==false`,`SongApiClient` 等 15 秒后只能回退到数据库里的 + * `cloud_url`,取不到就报「无法生成下载地址」。特征是**远程日志里连一条 `JS ...` 都没有** + * (JS 从没跑起来)。 + * + * ## 做法 + * 用 Kotlin 复刻 `ktv_api.js` 的同一套协议: + * `GET /i.php`(取 token)→ `GET /music/do.php`(取播放地址), + * 并镜像 JS 的多源排序(失败源沉底)与广告/错号资源校验(`isDemoUrl`)。 + * + * ⚠️ 它**必须走与 JS 相同的 HTTP 通道**([KtvJsBridge.rawCleanGet] 的裸 Socket, + * 只发 Host + Accept),否则会被源站风控拒绝(带 UA/Connection 头会被判"异常行为")。 + * + * ⚠️ 这里的 APP_ID/APP_KEY/SDK_KEY/HOSTS 是 `ktv_api.js` CONFIG 的镜像。 + * 源站配置若变更,JS 可热更新而这里需要发版 —— 因此它**只作为兜底**: + * WebView 可用时仍以 JS 为准(见 [SongApiClient.getSongDownloadUrl])。 + */ +object SongUrlResolver { + private const val TAG = "SongUrlResolver" + + // ── 以下与 assets/mobile/ktv_api.js 的 CONFIG 保持一致 ── + // 注意 VER/VN 与 KtvJsBridge 注入 JS 时覆盖的值一致(不是 JS 文件里的原始值)。 + private const val APP_ID = "d4eeacc6cec3434fbc8c41608a3056a0" + private const val APP_KEY = "024210cba40d4385a93e6c2d3249bfb5" + private const val SDK_KEY = "19042303a8374f67ae3fe1e25c97936f" + private const val VER = "2.0" + private const val VN = "4.1.3.03281430" + private const val TIMEOUT_MS = 8_000 + + private val DEFAULT_HOSTS = listOf( + "http://w.w345.my", + "http://desktok.top", + "http://mm.kk456.top", + "http://w.w345.my:66", + "http://z.w345.my", + "http://b.sktv.top", + "http://w345.my", + ) + + /** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */ + private val hostOrder: MutableList = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList()) + + @Volatile private var fetch: ((String) -> String?)? = null + + @Volatile private var sn: String = KtvJsBridge.DEVICE_SN + + /** + * 安装取址通道。 + * @param fetch 干净的 HTTP GET(应传 [KtvJsBridge.rawCleanGet]); + * 入参 url,返回 `{"status":..,"body":..,"error":..}` 的 JSON 字符串。 + */ + fun install(fetch: (String) -> String?) { + this.fetch = fetch + Log.i(TAG, "native song url resolver installed (${DEFAULT_HOSTS.size} hosts)") + } + + private fun demote(host: String) { + synchronized(hostOrder) { + val index = hostOrder.indexOf(host) + if (index >= 0 && index < hostOrder.size - 1) { + hostOrder.removeAt(index) + hostOrder.add(host) + } + } + } + + private fun promote(host: String) { + synchronized(hostOrder) { + val index = hostOrder.indexOf(host) + if (index > 0) { + hostOrder.removeAt(index) + hostOrder.add(0, host) + } + } + } + + /** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */ + fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? { + if (musicno.isBlank()) return null + val channel = fetch ?: run { + MiliLog.warn("NATIVE-SONG 未安装取址通道,跳过原生兜底") + return null + } + val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn) + val startedAt = System.currentTimeMillis() + val hosts = synchronized(hostOrder) { hostOrder.toList() } + var lastDetail = "no-source" + + for ((index, host) in hosts.withIndex()) { + val token = runCatching { fetchToken(channel, host, mac) }.getOrElse { + MiliLog.warn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}") + null + } + if (token.isNullOrEmpty()) { + lastDetail = "token-fail" + demote(host) + continue + } + val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) } + .getOrElse { + MiliLog.warn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}") + null + } + if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) { + promote(host) + val elapsed = System.currentTimeMillis() - startedAt + val urlHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "?" + MiliLog.info( + "NATIVE-SONG 取址成功 编号=$musicno 源=$host urlHost=$urlHost " + + "第${index + 1}个源 用时=${elapsed}ms", + ) + return url + } + lastDetail = when { + url.isNullOrEmpty() -> "no-url" + else -> "demo-or-mismatch" + } + demote(host) + } + + val elapsed = System.currentTimeMillis() - startedAt + MiliLog.error("NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms") + return null + } + + // ─── 协议实现 ─────────────────────────────────────── + + private fun fetchToken(channel: (String) -> String?, host: String, mac: String): String? { + val ts = System.currentTimeMillis() / 1000 + val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN" + val sign = md5(params + APP_KEY) + val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null + if (json.optInt("code") != 200) { + MiliLog.warn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}") + return null + } + return json.optString("token").takeIf { it.isNotEmpty() } + } + + private fun fetchSongUrl( + channel: (String) -> String?, + host: String, + mac: String, + musicno: String, + token: String, + resolution: String, + h265: Boolean, + ): String? { + val ts = System.currentTimeMillis() / 1000 + // 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 一致(同 ktv_api.js)。 + val params = "appid=$APP_ID&device=${mac}_$sn&ish265=${if (h265) "1" else "0"}&ls=1" + + "&musicno=$musicno&resolution=$resolution&sn=$sn&time=$ts&token=$token" + val sign = md5(params + SDK_KEY) + val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null + val code = json.optInt("code") + if (code != 200) { + MiliLog.warn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}") + return null + } + return json.optString("data").takeIf { it.isNotEmpty() } + } + + /** 解析 request() 的信封 `{status, body, error}`,再把 body 解析为 JSON。 */ + private fun getJson(channel: (String) -> String?, url: String): JSONObject? { + val raw = channel(url) ?: return null + val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return null + val status = envelope.optInt("status") + if (status !in 200..299) { + MiliLog.warn( + "NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " + + "err=${envelope.optString("error").take(80)}", + ) + return null + } + val body = envelope.optString("body") + if (body.isBlank()) return null + return runCatching { JSONObject(body) }.getOrElse { + MiliLog.warn("NATIVE-SONG 响应非 JSON: ${body.take(120)}") + null + } + } + + /** 镜像 ktv_api.js 的 `isDemoUrl`:广告视频 / 纯 IP / R2 测试桶 / ts 编号与请求不一致。 */ + private fun isValidSongUrl(url: String, musicno: String): Boolean { + if (AdVideoFilter.isAdVideo(url)) return false + val lower = url.lowercase() + if (lower.contains("wb66") || lower.contains("demo")) return false + if (IP_URL.containsMatchIn(url)) return false + if (R2_DEMO.containsMatchIn(url)) return false + val matched = TS_NAME.find(url)?.groupValues?.getOrNull(1) + if (matched != null && matched != musicno) return false + return true + } + + private fun md5(text: String): String = + MessageDigest.getInstance("MD5").digest(text.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + private val IP_URL = Regex("^https?://\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(?::\\d+)?/", RegexOption.IGNORE_CASE) + private val R2_DEMO = Regex("pub-[a-z0-9]+\\.r2\\.dev/ss/", RegexOption.IGNORE_CASE) + private val TS_NAME = Regex("/(\\d+)\\.ts(?:\\?|$)") +}