From 62216b0fb8b4db48c40acdd22b8a7170de358452 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Sat, 26 Sep 2026 16:06:27 +0800 Subject: [PATCH] =?UTF-8?q?V1.4.3:=20=E4=BF=AE=E5=A4=8D=E8=80=81=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F(API<24)=E4=B8=8D=E8=AE=A4=20Let's=20Encrypt=20?= =?UTF-8?q?=E6=96=B0=E6=A0=B9=EF=BC=8C=E5=AF=BC=E8=87=B4=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=9B=B2=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因(用远程日志的 STORAGE 行 + 本地复现 Muse 协议定位): - 曲库服务 mconn.cherryonline.cn 与点歌登录 mws.cherryonline.cn 都用 Let's Encrypt 新链 leaf ← YR2 ← Root YR ← ISRG Root X1。 - res/xml/network_security_config.xml 里虽然已把这三个根加为信任锚,但 **该配置只在 Android 7.0(API 24)+ 被系统读取**;Android 5.1.1 上被完全忽略, 系统信任库又没有 ISRG 新根 → SSLHandshakeException: Trust anchor for certification path not found → login 失败 → fetchVersion()=null → 根本不触发下载, 界面只剩兜底文案「未找到可用曲库」,而失败只写 logcat,远程日志里看不到。 - 这也解释了为什么只有这台老设备出问题(其它设备都 >= API 24)。 修复: - 新增 TlsCompat: 在 API<24 上把「系统默认信任 ∪ 内置根证书」的复合 X509TrustManager 装到全局 HttpsURLConnection.setDefaultSSLSocketFactory,覆盖 App 内所有 HttpsURLConnection (曲库同步/JS 热更新下载/更新检查/头像下载)。主机名校验保持系统默认,不放行任意证书。 - WebView 的 TLS 无法用 SSLContext 覆盖(JS 会直连 mws): 在 KtvJsBridge 的 onReceivedSslError 里不盲目 proceed,先用 TlsCompat.isHostTrustedByOurStore(host) 对同一主机独立校验,通过才放行。 - TlsCompat.install 在 KtvApplication.attachBaseContext 调用(早于任何网络请求)。 - 补齐可观测性: 曲库同步每一步失败改为 MiliLog 上报 (MUSE-DB login/sync ...、DB-SYNC download failed/空间不足/成功); fetchRemoteVersion 失败时设置明确的 bootstrapError,不再只显示兜底文案。 - AGENTS.md 增补「老系统 TLS 根证书」条目。 版本 1.4.2(22) → 1.4.3(23);签名证书不变。 --- AGENTS.md | 22 ++- app/build.gradle | 4 +- .../com/local/ktv/DatabaseBootstrapper.kt | 9 +- .../main/java/com/local/ktv/KtvApplication.kt | 4 + .../main/java/com/local/ktv/KtvJsBridge.kt | 25 +++ .../main/java/com/local/ktv/MainActivity.kt | 8 + app/src/main/java/com/local/ktv/MuseDbSync.kt | 13 +- app/src/main/java/com/local/ktv/TlsCompat.kt | 168 ++++++++++++++++++ 8 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/local/ktv/TlsCompat.kt diff --git a/AGENTS.md b/AGENTS.md index 7fbe3de..fb0aa55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.4.2"` / `versionCode 22`(18~21 被本机临时诊断包占用过) +- `app/build.gradle`:`versionName "1.4.3"` / `versionCode 23`(18~21 被本机临时诊断包占用过) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 @@ -206,6 +206,26 @@ $BT/apksigner verify 目标名.apk 换盒子/换 ROM 时先看这行就能确认存储根落在哪。 - 自查:`grep -rn "/storage/emulated\|/sdcard/MaidongKTV" app/src/main/java --include='*.kt'` 除 `AppPaths` 的候选清单外不应再有硬编码。 +- **⚠️ 老系统(API < 24)的 TLS 根证书**:`*.cherryonline.cn`(曲库同步 `mconn` / 点歌登录 `mws`) + 与 `gitea.kasugano.cn` 都用 **Let's Encrypt 新链**(`leaf ← YR2 ← Root YR ← ISRG Root X1`)。 + `res/xml/network_security_config.xml` 里的三个内置根证书**只在 Android 7.0(API 24)+ 生效**, + Android 5.1.1 上被完全忽略、系统信任库又没有 ISRG 新根 → + `SSLHandshakeException: Trust anchor for certification path not found` → + 曲库登录失败 →**「不能下载曲库」**(这就是极米 H1S 上第二个卡点的根因,2026-09-26 定位)。 + 约定: + 1. `TlsCompat.install(context)`(在 `KtvApplication.attachBaseContext`)在 API<24 上把 + 「系统默认信任 ∪ 内置根证书」的复合 `X509TrustManager` 装到全局 + `HttpsURLConnection.setDefaultSSLSocketFactory`,一次覆盖 App 内所有 `HttpsURLConnection` + (曲库同步、JS 热更新下载、更新检查、头像下载)。**主机名校验保持系统默认,绝不放行任意证书**; + 2. WebView 的 TLS 信任**无法**用 SSLContext 覆盖,而 JS(`ktv_api.js`)会直连 + `https://mws.cherryonline.cn`。因此在 `KtvJsBridge` 的 `onReceivedSslError` 里**绝不盲目 + `proceed()`**:先用 `TlsCompat.isHostTrustedByOurStore(host)` 对同一主机独立做一次 TLS 校验, + 通过才放行,否则 `cancel()`; + 3. 以后替换/新增内置根证书时,`TlsCompat.BUNDLED_CERTS` 与 `network_security_config.xml` + 两处要同步改(一个管 API<24、一个管 API>=24); + 4. 曲库同步每一步失败都会 `MiliLog.warn("MUSE-DB ..." / "DB-SYNC ...")` 上报。 + 老设备上「不能下载曲库」先看远程日志这几行 —— 以前只写 logcat,界面只剩 + 「未找到可用曲库」兜底文案,排查极其困难。 - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底) - **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 diff --git a/app/build.gradle b/app/build.gradle index 3475c95..97a34e8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 22 - versionName "1.4.2" + versionCode 23 + versionName "1.4.3" 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/DatabaseBootstrapper.kt b/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt index 7f916f3..91cf933 100644 --- a/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt +++ b/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt @@ -72,6 +72,7 @@ object DatabaseBootstrapper { val file = MuseDbSync.download(onProgress) // MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。 markSynced() + MiliLog.info("DB-SYNC download OK: ${file.absolutePath} size=${formatSize(file.length())}") file }.mapError(::readableError) } @@ -89,14 +90,20 @@ object DatabaseBootstrapper { private fun readableError(error: Throwable): Throwable { if (isNoSpaceError(error)) { val target = MuseDatabase.defaultDbFile() + val free = target.parentFile?.usableSpace ?: 0L + MiliLog.warn( + "DB-SYNC 空间不足: free=${formatSize(free)} target=${target.absolutePath} " + + "err=${error.message}", + ) return IllegalStateException( - "曲库分区空间不足(当前可用 ${formatSize(target.parentFile?.usableSpace ?: 0L)})," + + "曲库分区空间不足(当前可用 ${formatSize(free)})," + "已清理下载临时文件,请释放空间后重试", error, ) } val message = error.message?.takeIf { it.isNotBlank() } ?: "曲库下载失败" Log.w(TAG, "Database download failed: $message") + MiliLog.warn("DB-SYNC download failed: ${error.javaClass.simpleName}: $message") return IllegalStateException(message, error) } } diff --git a/app/src/main/java/com/local/ktv/KtvApplication.kt b/app/src/main/java/com/local/ktv/KtvApplication.kt index f95c01b..4fe86d0 100644 --- a/app/src/main/java/com/local/ktv/KtvApplication.kt +++ b/app/src/main/java/com/local/ktv/KtvApplication.kt @@ -16,6 +16,10 @@ class KtvApplication : Application() { base?.let { runCatching { AppPaths.init(it) } .onFailure { error -> Log.e(TAG, "AppPaths.init failed", error) } + // 老系统(API<24)不读 network_security_config,需要代码补上内置根证书的信任, + // 否则 Let's Encrypt 新链的接口(曲库同步/点歌登录/JS 热更新)全部握手失败。 + runCatching { TlsCompat.install(it) } + .onFailure { error -> Log.e(TAG, "TlsCompat.install failed", error) } } } diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index 6e0a218..02e438d 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -1,11 +1,14 @@ 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 @@ -108,6 +111,28 @@ class KtvJsBridge(context: Context) { 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") { diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index 6e03776..7d8fe3f 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -644,7 +644,15 @@ class MainActivity : AppCompatActivity() { DatabaseBootstrapper.markSynced() } } else { + // 取远端版本失败 = 登录/同步没成功(老设备常见于 TLS 根证书缺失)。 + // 以前这里只写 logcat 且不设 bootstrapError,界面只会显示兜底的 + // 「未找到可用曲库」,远程日志里也什么都看不到 —— 排查非常困难。 + bootstrapError = IllegalStateException("无法连接曲库服务器(获取远端版本失败)") Log.w(TAG, "曲库同步到期,但获取远端版本失败,本次继续使用本地曲库") + MiliLog.warn( + "DB-SYNC fetchRemoteVersion=null localDbOk=$ok syncDue=$syncDue " + + "sdk=${android.os.Build.VERSION.SDK_INT} root=${AppPaths.root.absolutePath}", + ) } } else { Log.i(TAG, "曲库未到期(${DatabaseBootstrapper.syncStatusText()}),跳过服务器同步") diff --git a/app/src/main/java/com/local/ktv/MuseDbSync.kt b/app/src/main/java/com/local/ktv/MuseDbSync.kt index 9d0b4cb..4c01912 100644 --- a/app/src/main/java/com/local/ktv/MuseDbSync.kt +++ b/app/src/main/java/com/local/ktv/MuseDbSync.kt @@ -143,13 +143,18 @@ object MuseDbSync { setRequestProperty("User-Agent", "MaidongKTV/1.2") } conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) } - if (conn.responseCode !in 200..299) return null + if (conn.responseCode !in 200..299) { + MiliLog.warn("MUSE-DB login HTTP ${conn.responseCode} (url=$url)") + return null + } conn.inputStream.use { it.readBytes() } conn.getHeaderField("Set-Cookie") ?.substringBefore(';') ?.takeIf { it.startsWith("muse-portal-connector=") } } catch (error: Throwable) { Log.w(TAG, "login failed: ${error.message}") + // 老设备上最常见的就是证书链校验失败(SSLHandshakeException),必须上报。 + MiliLog.warn("MUSE-DB login error: ${error.javaClass.simpleName}: ${error.message}") null } finally { conn?.disconnect() @@ -171,11 +176,15 @@ object MuseDbSync { setRequestProperty("User-Agent", "MaidongKTV/1.2") } conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) } - if (conn.responseCode !in 200..299) return null + if (conn.responseCode !in 200..299) { + MiliLog.warn("MUSE-DB sync HTTP ${conn.responseCode} (url=$url)") + return null + } val text = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } JSONObject(text) } catch (error: Throwable) { Log.w(TAG, "sync failed: ${error.message}") + MiliLog.warn("MUSE-DB sync error: ${error.javaClass.simpleName}: ${error.message}") null } finally { conn?.disconnect() diff --git a/app/src/main/java/com/local/ktv/TlsCompat.kt b/app/src/main/java/com/local/ktv/TlsCompat.kt new file mode 100644 index 0000000..fb0cabb --- /dev/null +++ b/app/src/main/java/com/local/ktv/TlsCompat.kt @@ -0,0 +1,168 @@ +package com.local.ktv + +import android.content.Context +import android.util.Log +import java.net.URL +import java.security.KeyStore +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.concurrent.ConcurrentHashMap +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +/** + * 低版本 Android 的 TLS 信任补丁。 + * + * **背景**:`*.cherryonline.cn`(曲库同步 `mconn` / 点歌登录 `mws`)与 `gitea.kasugano.cn` + * 都使用 Let's Encrypt 新链:`leaf ← YR2 ← Root YR ← ISRG Root X1`。 + * `res/xml/network_security_config.xml` 里虽然把这三个根证书加了信任锚,但 + * **该配置只在 Android 7.0(API 24) 及以上被系统读取**;Android 5.1.1(API 22) 上被完全忽略, + * 系统信任库又没有 ISRG 新根 → `SSLHandshakeException: Trust anchor for certification path not found` + * → 曲库登录失败 → 「不能下载曲库」。这也解释了为什么只有老设备出问题(其它设备都是 API 24+)。 + * + * **做法**(只对 API < 24 生效,新系统保持原样): + * 1. [install] 构造「系统默认信任 ∪ 应用内置根证书」的复合 X509TrustManager,装到全局 + * `HttpsURLConnection` 默认 SSLSocketFactory 上 —— 覆盖 App 内所有 `HttpsURLConnection` + * (曲库同步、JS 热更新下载、更新检查、头像下载等)。 + * **主机名校验保持系统默认,不关闭、不放行任意证书。** + * 2. WebView 的 TLS 信任无法用 SSLContext 覆盖,故提供 [isHostTrustedByOurStore]: + * 用上面增强后的信任库**独立地对同一主机发起一次 TLS 校验**;只有当它校验通过 + * (说明证书链本身合法、主机名也匹配,WebView 失败纯粹是因为老系统的根证书缺失)时, + * 才允许 WebView 继续。**不做「见到 SSL 错误就放行」的盲目信任。** + */ +object TlsCompat { + private const val TAG = "TlsCompat" + + /** 应用内置的根/中间证书(与 network_security_config.xml 中列出的保持一致)。 */ + private val BUNDLED_CERTS = intArrayOf( + R.raw.isrg_root_x1, + R.raw.isrg_root_yr, + R.raw.letsencrypt_yr1, + ) + + @Volatile private var installed = false + + /** 主机 → 是否已被增强信任库校验通过(只缓存肯定结果)。 */ + private val trustedHosts = ConcurrentHashMap() + + /** 是否跑在需要补丁的老系统上(API < 24 才会忽略 network_security_config)。 */ + val needsLegacyPatch: Boolean get() = android.os.Build.VERSION.SDK_INT < 24 + + fun install(context: Context) { + if (installed) return + installed = true + runCatching { + val system = systemTrustManager() + val bundled = bundledTrustManager(context) + if (bundled == null) { + Log.w(TAG, "no bundled certs loaded; keep system trust only") + return + } + if (android.os.Build.VERSION.SDK_INT >= 24) { + // 新系统由 network_security_config 负责,无需接管(避免多此一举)。 + Log.i(TAG, "API>=24, network-security-config handles bundled roots") + return + } + val composite = CompositeTrustManager(system, bundled) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(composite), null) + HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) + Log.i( + TAG, + "legacy TLS trust installed: system(${system?.acceptedIssuers?.size ?: 0} CAs) " + + "+ bundled(${BUNDLED_CERTS.size} certs)", + ) + }.onFailure { Log.e(TAG, "install failed", it) } + } + + /** + * 用**增强后的信任库**校验 `https://host/` 的证书链。 + * 返回 true 仅当 TLS 握手与主机名校验都通过(HttpsURLConnection 的默认主机名校验保持开启)。 + */ + fun isHostTrustedByOurStore(host: String): Boolean { + if (host.isBlank()) return false + trustedHosts[host]?.let { return it } + val ok = runCatching { + val conn = (URL("https://$host/").openConnection() as HttpsURLConnection).apply { + connectTimeout = 8000 + readTimeout = 8000 + requestMethod = "HEAD" + instanceFollowRedirects = false + } + try { + // 任何响应码都说明握手 + 主机名校验通过了。 + conn.responseCode + true + } catch (e: SSLException) { + Log.w(TAG, "host $host NOT trusted by our store: ${e.message}") + false + } finally { + runCatching { conn.disconnect() } + } + }.getOrDefault(false) + if (ok) trustedHosts[host] = true + return ok + } + + private fun systemTrustManager(): X509TrustManager? = runCatching { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + factory.trustManagers.filterIsInstance().firstOrNull() + }.getOrNull() + + private fun bundledTrustManager(context: Context): X509TrustManager? = runCatching { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) } + val certificateFactory = CertificateFactory.getInstance("X.509") + BUNDLED_CERTS.forEachIndexed { index, resId -> + context.resources.openRawResource(resId).use { input -> + certificateFactory.generateCertificate(input)?.let { + keyStore.setCertificateEntry("ktv-bundled-$index", it) + } + } + } + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(keyStore) + factory.trustManagers.filterIsInstance().firstOrNull() + }.onFailure { Log.e(TAG, "load bundled certs failed", it) }.getOrNull() + + /** 系统信任 ∪ 内置根证书:任一通过即放行;都不过则抛出首个错误。 */ + private class CompositeTrustManager( + private val system: X509TrustManager?, + private val bundled: X509TrustManager?, + ) : X509TrustManager { + + override fun checkClientTrusted(chain: Array, authType: String) { + var first: CertificateException? = null + for (manager in listOfNotNull(system, bundled)) { + try { + manager.checkClientTrusted(chain, authType) + return + } catch (e: CertificateException) { + if (first == null) first = e + } + } + throw first ?: CertificateException("no trust manager available") + } + + override fun checkServerTrusted(chain: Array, authType: String) { + var first: CertificateException? = null + for (manager in listOfNotNull(system, bundled)) { + try { + manager.checkServerTrusted(chain, authType) + return + } catch (e: CertificateException) { + if (first == null) first = e + } + } + throw first ?: CertificateException("no trust manager available") + } + + override fun getAcceptedIssuers(): Array = + (system?.acceptedIssuers ?: emptyArray()) + (bundled?.acceptedIssuers ?: emptyArray()) + } +}