From eadc2d4de141e19509c43d381e42bb0b8de6cd5a Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Sat, 26 Sep 2026 21:25:37 +0800 Subject: [PATCH] =?UTF-8?q?V1.5.0:=20=E4=BF=AE=E5=A4=8D"=E5=85=AC=E6=94=BE?= =?UTF-8?q?=E5=88=87=E6=AD=8C=E5=A4=B1=E6=95=88"=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E7=89=88=E6=B8=85=E7=A9=BA=E6=AD=8C=E6=9B=B2?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=8A=8A=E5=85=AC=E6=94=BE=E6=9B=B2=E5=BA=93?= =?UTF-8?q?=E5=88=A0=E4=BA=86=EF=BC=88=E6=96=B0=E8=AE=BE=E5=A4=87=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: 新设备公放时按「切歌」没有任何反应。 根因(我上一版引入的回归): - 1.4.10 为了让"被当成歌曲缓存下来的广告视频"消失,在启动时做了一次 **整目录清理**(删 cloud-song 下所有 .ts/.ls/.mp4/.mkv)。 - 但 MuseDatabase.songDirectories() **只有 cloudSongsDir 一个目录**, SongOkDownloadManager.getLocalFile() 也落在那里 —— 那等于**把公放曲库整个删掉**。 - 后果链: 库空 → playRandomLocalSong() 里 allLocalSongs 为空 → RandomPlayQueue.next() 返回 null → 状态栏"本地暂无可播放歌曲"、currentSong 保持 null → 按「切歌」时 advancePlayback 落进「取消随机续播、进入空闲态」分支 → 按钮无反应。 (RandomPlayQueue.next 本身有"只剩一首也能播"的兜底,队列逻辑没问题。) 修复: - 删除整目录清理,改为 purgeInjectedAdDuplicatesOnce(): **只删能证明是注入广告的文件** —— 广告对每首歌下发同一个视频,特征是"多首不同歌曲的本地文件内容完全相同" (按文件大小 + 头部 4MB 指纹分组,只清理 size>=2 的完全相同组)。 正常歌曲内容各不相同,绝不会被误删;公放曲库得以保留。 同时按用户要求,复查"老设备适配期间改动对新设备的影响",另修正两处: - SongApiClient.getSongDownloadUrl 的主线程分支: 1.4.7 起会先走 resolveNatively() (最多 7 个源 × 8 秒超时的阻塞网络请求)—— 一旦有主线程调用者就会卡死 UI 到 ANR。 现在主线程只做本地 DB 兜底,网络取址一律留在后台线程。 - KtvJsBridge 裸 Socket 的 HTTPS 分支: 1.4.6 改用了无参 createSocket(),拿到的 SSLSocket **不带主机名**(影响 SNI 与证书主机名校验,共享 CDN/反代多证书时会握手失败)。 改为先 connect 普通 socket(保留显式连接超时)再 createSocket(plain, host, port, true)。 - buildDownloadUrl 的文件名比对只在点歌编号"可信"时启用: 只有从 song.filename 推出的编号才 参与比对;song.id 可能是数据库主键,拿它比对会误杀正常地址(真源被判"文件名不符")。 (另已实测 7 个源: 可用的 5 个真源 URL 文件名全部严格等于点歌编号,规则不会误杀。) 顺带记录用户给出的版本号约定(已写入 AGENTS.md): versionName = ..,patch 0~10、minor 1~10; 1.4.10 之后是 1.5.0,1.10.1 之后是 2.1.0;versionCode 始终 +1。 版本 1.4.10(30) → 1.5.0(31);签名证书不变。测试 8 例全通过。 --- AGENTS.md | 37 +++++++-- app/build.gradle | 4 +- .../main/java/com/local/ktv/KtvJsBridge.kt | 10 ++- .../main/java/com/local/ktv/MainActivity.kt | 8 +- .../main/java/com/local/ktv/SongApiClient.kt | 7 +- .../com/local/ktv/SongOkDownloadManager.kt | 82 ++++++++++++++----- 6 files changed, 113 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d06026f..d8105fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,13 @@ 把积压的 commit 一并推上即可。 2. **Gitee 不再同步代码,只发布版本**:Gitee 仓库保持「仅 README 文档 + Releases 附件」状态, **禁止推送源码**(`pre-push` 钩子会拦截 `gitee.com`)。Gitee 上只做一件事:创建 Release 并上传 APK 附件。 -3. **每次更新版本号 +1**:完成一次修改就把版本号递增(`versionName` 与 `versionCode` 同步递增), - 例如 `1.3.0` → `1.3.1` → `1.3.2`。不要在同一个版本号下反复改动交付。 +3. **版本号递增规则(2026-09-26 更新)**:`versionName` 形如 `..`, + **patch 取值 0~10、minor 取值 1~10**,每次修改先用掉一个 patch: + - 常规递增:`1.5.0` → `1.5.1` → … → `1.5.10`; + - patch 到 10 后再改 → **进位到 minor、patch 归 0**:`1.4.10` → `1.5.0`; + - minor 到 10 后再进位 → **major +1、minor 归 1、patch 归 0**:`1.10.1` → `2.1.0`。 + `versionCode` 始终同步 +1(单调递增,供系统与更新检查判断新旧)。 + 不要在同一个版本号下反复改动交付。 4. **发布是手动操作**:**未收到明确的「发布新版本」指令时,默认只在本地打包测试**—— 产物按内部测试约定命名 `maidong-ktv-<版本>-<改动说明>-双ABI-release.apk`, **不创建 Release、不上传附件、不推送 tag、不在 Gitee 建 Release**。 @@ -160,7 +165,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.4.10"` / `versionCode 30`(18~21 被本机临时诊断包占用过) +- `app/build.gradle`:`versionName "1.5.0"` / `versionCode 31`(18~21 被本机临时诊断包占用过) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 @@ -287,10 +292,32 @@ $BT/apksigner verify 目标名.apk 3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整), 所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件** (`purgeCachedSongFile`),否则 `isDownloaded()` 会一直复用那个广告文件,广告反复播; - 4. 历史遗留由 `SongOkDownloadManager.purgeLegacySongCacheOnce()` 在升级后清一次 - (marker:`filesDir/song_cache_purged_v1`)。清掉只是让歌曲重新下载,不影响用户数据; + 4. 历史遗留由 `SongOkDownloadManager.purgeInjectedAdDuplicatesOnce()` 在升级后清一次 + (marker:`filesDir/ad_dup_purge_v1`),**只删「多首不同歌曲内容完全相同」的文件**: + 广告对每首歌都下发同一个视频,所以它的特征是"多首歌共用同一段视频"; + **绝不能图省事直接清空整个歌曲目录**(1.4.10 这么干过,见下条); 5. 回归测试 `app/src/test/java/com/local/ktv/AdVideoFilterTest.java` 用日志里的**真实 URL 形态** 锁住行为(含两个真源 CDN 域),改这块逻辑必须先让它通过。 + - ⚠️ **文件名比对只在编号可信时启用**:`buildDownloadUrl` 里只有"从 `song.filename` 推出"的 + 点歌编号才传给 `isTrustedSongUrl`;`song.id` 可能是数据库主键,拿它比对会把**正常地址误杀** + (表现为真源被判"文件名不符"→ 永远下不到歌)。 +- **⚠️ 本地歌曲缓存 = 公放曲库,绝不能整目录清空**(2026-09-26 新设备回归): + `MuseDatabase.songDirectories()` **只有 `cloudSongsDir` 一个目录**, + `SongOkDownloadManager.getLocalFile()` 也落在那里 —— 所以"清理歌曲缓存"就等于**删掉公放曲库**。 + 1.4.10 在启动时做过一次整目录清理,结果:库里没歌 → `playRandomLocalSong()` 选不出候选 + → `currentSong` 保持 null → 按「切歌」时 `advancePlayback` 落进 + 「取消随机续播、进入空闲态」分支 → **按钮按了完全没反应**(用户报「公放时切歌失效」)。 + 约定:**任何"清理缓存"的改动都必须先确认它会删掉哪些目录**;要清理就只删**能证明有问题**的 + 文件(见上条),**永远不要动用户已下载的正常歌曲**。 +- **⚠️ 主线程禁止网络 I/O**:`SongApiClient.getSongDownloadUrl` 的主线程分支只允许走 + 本地 DB 兜底(`fetchFromDb`)。1.4.7 曾在该分支先调 `resolveNatively()` —— + 原生兜底最多 7 个源 × 8 秒超时,一旦有主线程调用者就会**卡死 UI 到 ANR**。 + 取址(JS 桥 / 原生兜底)一律只允许在后台线程发起。 +- **⚠️ 裸 Socket 的 HTTPS 必须带主机名**:用 `TlsCompat.sslSocketFactory()` 时必须 + `createSocket(plainSocket, host, port, true)`(**先连接、再按主机名包装**), + 不能用无参 `createSocket()` —— 后者拿到的 `SSLSocket` **不带主机名**, + 会被 SNI 与证书主机名校验影响(共享 CDN / 反代多证书时直接握手失败)。 + 这样既保住显式连接超时,又补上 SNI。 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 - **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载) - **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建 diff --git a/app/build.gradle b/app/build.gradle index 07b7968..68a75a8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 30 - versionName "1.4.10" + versionCode 31 + versionName "1.5.0" 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 c13567d..ee1f192 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -551,8 +551,16 @@ class KtvJsBridge(context: Context) { // 用 TlsCompat 的工厂(系统信任 ∪ 内置根证书):裸 socket 走的是 // SSLSocketFactory.getDefault(),不受 HttpsURLConnection 的默认工厂影响, // network_security_config 也管不到它 —— 老设备上会因缺 ISRG 新根握手失败。 - (TlsCompat.sslSocketFactory().createSocket() as SSLSocket).apply { + // + // ⚠️ 必须用「先连接、再包装」的写法:`createSocket()`(无主机参数)拿到的 + // SSLSocket **不会带上主机名**,SNI 与证书主机名校验都会受影响(共享 CDN / + // 反代多证书时直接握手失败)。`createSocket(plain, host, port, true)` 既补上 + // 主机名,又保住了上面显式设置的连接超时。 + val plain = Socket().apply { connect(java.net.InetSocketAddress(uri.host, port), connectTimeout) + } + (TlsCompat.sslSocketFactory() + .createSocket(plain, uri.host, port, true) as SSLSocket).apply { startHandshake() } } else { diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index d6f9692..1fc3214 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -529,10 +529,10 @@ class MainActivity : AppCompatActivity() { initializeDatabase() startRemoteServer() updateMobileQrOverlay() - // 一次性清理历史遗留的可疑歌曲缓存:1.4.9 及以前 JS 取址的地址没做广告校验, - // 广告视频会被当成歌曲落盘,且能通过内容校验被 isDownloaded() 一直复用(广告反复播)。 - // 清理只是让歌曲重新下载,不影响用户数据;之后由「取址即校验」保证不再产生。 - io.execute { SongOkDownloadManager.purgeLegacySongCacheOnce(applicationContext) } + // 一次性清理"同一段视频被当成多首不同歌曲"的注入广告副本。 + // ⚠️ 只删可证明是广告的文件(多首歌内容完全相同);**绝不能清空整个歌曲目录** —— + // 1.4.10 那样做过,等于删掉公放曲库,导致「切歌」选不出下一首(新设备回归)。 + io.execute { SongOkDownloadManager.purgeInjectedAdDuplicatesOnce(applicationContext) } main.postDelayed(remoteNetworkMonitor, 5000) // 网络状态监测: 断网提示 + 恢复后重建歌曲源会话/重试下载。 KtvNetworkMonitor.start(applicationContext) { online, initial -> diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt index 4d7bc84..8041a0f 100644 --- a/app/src/main/java/com/local/ktv/SongApiClient.kt +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -50,9 +50,12 @@ object SongApiClient { @JvmStatic fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? { // 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。 + // ⚠️ 主线程**绝不能**走网络路径:原生兜底最多 7 个源 × 8 秒超时,会把 UI 线程 + // 卡死到 ANR(1.4.7 曾把这里改成先 resolveNatively 再回退,属于隐患)。 + // 主线程只做本地的 DB 兜底,网络取址一律留给后台线程。 if (Looper.myLooper() == Looper.getMainLooper()) { - Log.w(TAG, "Synchronous JS request on main thread; using native/DB fallback") - return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) + Log.w(TAG, "Synchronous request on main thread; using DB fallback only") + return fetchFromDb(musicno) } val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) // 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。 diff --git a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt index bd28cf8..fe99fe4 100644 --- a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt +++ b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt @@ -77,40 +77,75 @@ object SongOkDownloadManager { } /** - * 一次性清理历史遗留的歌曲缓存(广告漏放期间的产物)。 + * 一次性清理"同一段视频被当成多首不同歌曲"的注入广告。 * * 背景(2026-09-26 事故):1.4.9 及以前,JS 取址返回的地址在 `buildDownloadUrl` 的成功分支里 - * **完全没有过广告校验**,广告视频(如 `gz.ac16.vip`)会被当成歌曲下载并落盘;它又能通过 - * [SongFileValidator] 的内容校验(≥6MB / ≥60s / TS 包完整),因此**靠内容无法识别**, + * **完全没有过广告校验**,广告视频(如 `gz.ac16.vip/ad_files/my_ad_video.ts`)会被当成歌曲 + * 下载并落盘;它又能通过 [SongFileValidator] 的内容校验(≥6MB / ≥60s / TS 包完整), * 而且 [isDownloaded] 会直接复用该文件、连取址都不会再发生 —— 广告会一直播下去。 * - * 这类遗留文件只能在升级时清一次;此后由「取址即校验」( - * [AdVideoFilter.isTrustedSongUrl])保证不再产生。清掉只是让歌曲重新下载,不影响任何用户数据。 + * ⚠️ **不能像 1.4.10 那样直接清空整个歌曲目录**:`MuseDatabase.songDirectories()` 只有 + * `cloudSongsDir` 一个目录,那等于把**公放曲库整个删掉** —— 库里没歌, + * `playRandomLocalSong()` 选不出候选、`currentSong` 保持 null, + * 于是「切歌」落进空闲态分支、按了没反应(新设备上的回归)。 + * + * 这里改为**只删真正能证明是注入广告的文件**:广告对每首歌都下发**同一个视频文件**, + * 所以它的特征是「多首不同歌曲的本地文件**内容完全相同**」。按「文件大小 + 头部指纹」 + * 分组,只清理 ≥2 个文件完全相同的组 —— 正常歌曲各有各的内容,绝不会被误删。 * * @return 删除的文件数(已清理过则返回 0) */ @JvmStatic - fun purgeLegacySongCacheOnce(context: android.content.Context): Int { - val marker = File(context.filesDir, "song_cache_purged_v1") + fun purgeInjectedAdDuplicatesOnce(context: android.content.Context): Int { + val marker = File(context.filesDir, "ad_dup_purge_v1") if (marker.exists()) return 0 var removed = 0 + var groups = 0 runCatching { - val dir = File("${MuseDatabase.VIDEO_ROOT}/${MuseDatabase.CLOUD_SONG_DIR}") - dir.listFiles()?.forEach { file -> - if (file.isFile && file.extension.lowercase() in MEDIA_EXTENSIONS) { - SongFileValidator.forget(file) - if (file.delete()) removed++ + val files = AppPaths.cloudSongsDir.listFiles().orEmpty() + .filter { it.isFile && it.extension.lowercase() in MEDIA_EXTENSIONS && it.length() > 0 } + // 第一层:按大小分组(同尺寸才会有嫌疑);第二层:比对头部指纹做双重确认。 + files.groupBy { it.length() } + .filterValues { it.size >= 2 } + .forEach { (_, sameSize) -> + sameSize.groupBy { headFingerprint(it) } + .forEach { (fingerprint, duplicates) -> + if (fingerprint != null && duplicates.size >= 2) { + groups++ + duplicates.forEach { file -> + SongFileValidator.forget(file) + if (file.delete()) removed++ + } + } + } } - } - marker.writeText("purged=${System.currentTimeMillis()} removed=$removed", Charsets.UTF_8) - }.onFailure { Log.w(TAG, "purge legacy song cache failed: ${it.message}") } - // 首次启动才会真正有动作,之后 marker 存在会直接返回 0,不产生噪声日志。 - if (removed > 0 || !marker.exists()) { - MiliLog.warn("SONG-AD 一次性清理历史歌曲缓存 removed=$removed(旧版本可能把广告缓存成歌曲)") + marker.writeText("purged=${System.currentTimeMillis()} removed=$removed groups=$groups", Charsets.UTF_8) + }.onFailure { Log.w(TAG, "purge injected ad duplicates failed: ${it.message}") } + if (removed > 0) { + MiliLog.warn( + "SONG-AD 清理注入广告副本 removed=$removed groups=$groups(多首歌共用同一段视频)" + + " 保留正常歌曲=${AppPaths.cloudSongsDir.listFiles()?.size ?: 0}", + ) } return removed } + /** 文件头部 4MB 的 MD5,用于"同一段视频"的判定(读全文件太慢,广告同一文件必然头部也相同)。 */ + private fun headFingerprint(file: File): String? = runCatching { + val digest = java.security.MessageDigest.getInstance("MD5") + file.inputStream().use { input -> + val buffer = ByteArray(1024 * 1024) + var total = 0 + while (total < 4 * 1024 * 1024) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + total += read + } + } + digest.digest().joinToString("") { "%02x".format(it) } + }.getOrNull() + @JvmStatic fun isDownloading(song: Song): Boolean { val key = songKey(song) @@ -211,10 +246,15 @@ object SongOkDownloadManager { } private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String { - val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id + // ⚠️ 只有「从文件名推出」的点歌编号才可信,才能参与 `.ts` 文件名比对。 + // `song.id` 在曲库条目里可能是数据库主键(非点歌编号),拿它去比对会把**正常地址误杀** + // (表现:真源地址被判"文件名不符"→ 永远下不到歌)。此时传 null = 跳过文件名规则, + // 仅保留广告域名/路径特征那两条判定。 + val musicNoFromFile = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls")?.takeIf { it.isNotBlank() } + val musicNo = musicNoFromFile ?: song.id if (!forceRefresh) { song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached -> - if (AdVideoFilter.isTrustedSongUrl(cached, musicNo)) return cached + if (AdVideoFilter.isTrustedSongUrl(cached, musicNoFromFile)) return cached // 缓存的地址不可信(广告 / 文件名与点歌编号不符):除了清地址,还要**删掉已落盘的 // 同名文件** —— 广告视频能通过内容校验(≥6MB / ≥60s / TS 包完整), // 一旦被缓存成歌曲,isDownloaded() 会直接复用、连取址都不会再发生。 @@ -230,7 +270,7 @@ object SongOkDownloadManager { // ⚠️ 广告漏放的**最终兜底防线**:不管地址来自 JS 取址、原生兜底还是 DB 回退, // 都必须过 AdVideoFilter.isTrustedSongUrl(2026-09-26 事故:JS 路径返回 // gz.ac16.vip 的广告视频,因为这里的成功分支完全没做广告校验而被当成歌曲下载播放)。 - if (url.isNotEmpty() && AdVideoFilter.isTrustedSongUrl(url, musicNo)) { + if (url.isNotEmpty() && AdVideoFilter.isTrustedSongUrl(url, musicNoFromFile)) { song.downloadUrl = url val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown" MiliLog.info(