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
+2 -2
View File
@@ -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,否则
@@ -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)
}
}
@@ -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) }
}
}
@@ -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") {
@@ -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()}),跳过服务器同步")
+11 -2
View File
@@ -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()
@@ -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())
}
}