V1.4.3: 修复老系统(API<24)不认 Let's Encrypt 新根,导致无法下载曲库

根因(用远程日志的 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);签名证书不变。
This commit is contained in:
zijunchiang
2026-09-26 16:06:27 +08:00
parent 8ad2aa50d7
commit 62216b0fb8
8 changed files with 247 additions and 6 deletions
+21 -1
View File
@@ -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)` 括号) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
@@ -206,6 +206,26 @@ $BT/apksigner verify 目标名.apk
换盒子/换 ROM 时先看这行就能确认存储根落在哪。 换盒子/换 ROM 时先看这行就能确认存储根落在哪。
- 自查:`grep -rn "/storage/emulated\|/sdcard/MaidongKTV" app/src/main/java --include='*.kt'` - 自查:`grep -rn "/storage/emulated\|/sdcard/MaidongKTV" app/src/main/java --include='*.kt'`
除 `AppPaths` 的候选清单外不应再有硬编码。 除 `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`(多源自动排序,失败源沉底) - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底)
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本 - **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv" applicationId "com.local.ktv"
minSdk 21 minSdk 21
targetSdk 25 targetSdk 25
versionCode 22 versionCode 23
versionName "1.4.2" versionName "1.4.3"
ndk { ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
@@ -72,6 +72,7 @@ object DatabaseBootstrapper {
val file = MuseDbSync.download(onProgress) val file = MuseDbSync.download(onProgress)
// MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。 // MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。
markSynced() markSynced()
MiliLog.info("DB-SYNC download OK: ${file.absolutePath} size=${formatSize(file.length())}")
file file
}.mapError(::readableError) }.mapError(::readableError)
} }
@@ -89,14 +90,20 @@ object DatabaseBootstrapper {
private fun readableError(error: Throwable): Throwable { private fun readableError(error: Throwable): Throwable {
if (isNoSpaceError(error)) { if (isNoSpaceError(error)) {
val target = MuseDatabase.defaultDbFile() 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( return IllegalStateException(
"曲库分区空间不足(当前可用 ${formatSize(target.parentFile?.usableSpace ?: 0L)})," + "曲库分区空间不足(当前可用 ${formatSize(free)})," +
"已清理下载临时文件,请释放空间后重试", "已清理下载临时文件,请释放空间后重试",
error, error,
) )
} }
val message = error.message?.takeIf { it.isNotBlank() } ?: "曲库下载失败" val message = error.message?.takeIf { it.isNotBlank() } ?: "曲库下载失败"
Log.w(TAG, "Database download failed: $message") Log.w(TAG, "Database download failed: $message")
MiliLog.warn("DB-SYNC download failed: ${error.javaClass.simpleName}: $message")
return IllegalStateException(message, error) return IllegalStateException(message, error)
} }
} }
@@ -16,6 +16,10 @@ class KtvApplication : Application() {
base?.let { base?.let {
runCatching { AppPaths.init(it) } runCatching { AppPaths.init(it) }
.onFailure { error -> Log.e(TAG, "AppPaths.init failed", error) } .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) }
} }
} }
@@ -1,11 +1,14 @@
package com.local.ktv package com.local.ktv
import android.content.Context import android.content.Context
import android.net.Uri
import android.net.http.SslError
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import org.json.JSONObject import org.json.JSONObject
@@ -108,6 +111,28 @@ class KtvJsBridge(context: Context) {
settings.domStorageEnabled = true settings.domStorageEnabled = true
addJavascriptInterface(NativeBridge(), "android") addJavascriptInterface(NativeBridge(), "android")
webViewClient = object : WebViewClient() { 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) { override fun onPageFinished(view: WebView, url: String) {
view.evaluateJavascript("typeof window.KtvApi === 'function'") { result -> view.evaluateJavascript("typeof window.KtvApi === 'function'") { result ->
if (result == "true") { if (result == "true") {
@@ -644,7 +644,15 @@ class MainActivity : AppCompatActivity() {
DatabaseBootstrapper.markSynced() DatabaseBootstrapper.markSynced()
} }
} else { } else {
// 取远端版本失败 = 登录/同步没成功(老设备常见于 TLS 根证书缺失)。
// 以前这里只写 logcat 且不设 bootstrapError,界面只会显示兜底的
// 「未找到可用曲库」,远程日志里也什么都看不到 —— 排查非常困难。
bootstrapError = IllegalStateException("无法连接曲库服务器(获取远端版本失败)")
Log.w(TAG, "曲库同步到期,但获取远端版本失败,本次继续使用本地曲库") 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 { } else {
Log.i(TAG, "曲库未到期(${DatabaseBootstrapper.syncStatusText()}),跳过服务器同步") Log.i(TAG, "曲库未到期(${DatabaseBootstrapper.syncStatusText()}),跳过服务器同步")
+11 -2
View File
@@ -143,13 +143,18 @@ object MuseDbSync {
setRequestProperty("User-Agent", "MaidongKTV/1.2") setRequestProperty("User-Agent", "MaidongKTV/1.2")
} }
conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) } 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.inputStream.use { it.readBytes() }
conn.getHeaderField("Set-Cookie") conn.getHeaderField("Set-Cookie")
?.substringBefore(';') ?.substringBefore(';')
?.takeIf { it.startsWith("muse-portal-connector=") } ?.takeIf { it.startsWith("muse-portal-connector=") }
} catch (error: Throwable) { } catch (error: Throwable) {
Log.w(TAG, "login failed: ${error.message}") Log.w(TAG, "login failed: ${error.message}")
// 老设备上最常见的就是证书链校验失败(SSLHandshakeException),必须上报。
MiliLog.warn("MUSE-DB login error: ${error.javaClass.simpleName}: ${error.message}")
null null
} finally { } finally {
conn?.disconnect() conn?.disconnect()
@@ -171,11 +176,15 @@ object MuseDbSync {
setRequestProperty("User-Agent", "MaidongKTV/1.2") setRequestProperty("User-Agent", "MaidongKTV/1.2")
} }
conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) } 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() } val text = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
JSONObject(text) JSONObject(text)
} catch (error: Throwable) { } catch (error: Throwable) {
Log.w(TAG, "sync failed: ${error.message}") Log.w(TAG, "sync failed: ${error.message}")
MiliLog.warn("MUSE-DB sync error: ${error.javaClass.simpleName}: ${error.message}")
null null
} finally { } finally {
conn?.disconnect() conn?.disconnect()
@@ -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<String, Boolean>()
/** 是否跑在需要补丁的老系统上(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<TrustManager>(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<X509TrustManager>().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<X509TrustManager>().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<out X509Certificate>, 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<out X509Certificate>, 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<X509Certificate> =
(system?.acceptedIssuers ?: emptyArray()) + (bundled?.acceptedIssuers ?: emptyArray())
}
}