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:
@@ -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`),不再依赖代码仓库数据库文件
|
||||
|
||||
+2
-2
@@ -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()}),跳过服务器同步")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user