16 Commits
Author SHA1 Message Date
zijunchiang b40a2bdf9b V1.5.1(重发): 修复"切歌→公放"在老设备(极米 H1S)上卡死
现象: 点歌列表切歌到公放列表,新设备正常,老设备(极米 H1S)卡死。

排查出三个叠加原因,且都是"新设备看不出来、老设备致命"的类型:

1. 切歌任务排在长任务后面(线程池饿死)
   切歌用的是 MainActivity.io(固定 4 线程),而"点歌即下即播"(streamDownloadAndPlay)
   这类几十秒的长任务也在同一个池里。H1S 网速慢,并发点几首歌就把池占满,
   切歌任务一直排队 → 旧歌播完后没声、按什么都没反应。
   修复: 切歌/随机续播改用专用单线程 randomPlayExecutor(守护线程),与下载彻底隔离。

2. 切歌时现扫目录找候选(慢 + 可能永久阻塞)
   候选来自 localSongInventory() + library.scanLocal():
   - SongFileValidator 对每个文件在头尾各读约 4.3MB 做 TS 校验;
   - scanLocal() 会遍历 /storage 各挂载点,老 ROM 上的失效挂载点会让 File.listFiles() 永久阻塞。
   修复: 启动时后台预热候选缓存(独立守护线程 + scanWithTimeout 4s 超时保护),
   下载完成即时补进缓存;切歌直接读缓存(零 I/O),缓存未就绪时只走安全的库存查询。

3. 主线程上做深度文件校验(直接 ANR)
   Song.hasLocalFile() 内部就是 SongFileValidator.inspect(头尾各读 4.3MB),
   却被用在 play()、播放错误回调、switchToLocalPlayback,
   以及**逐行渲染**的 songRowState() 里 —— 而 advancePlayback 恰好会调 renderQueue()。
   H1S 的慢存储上,队列每行一次深度校验就是数秒卡顿。
   修复: 播放/渲染路径统一改用廉价检查 hasPlayableLocalFile()(只看存在与大小),
   深度校验只保留在后台(localSongInventory() / isDownloaded())。

顺带修掉一个自己踩的坑: Kotlin 块注释会嵌套,KDoc 里写 /storage/* 会开启嵌套注释导致
KDoc 不闭合、后续代码被吞(报错为 Unclosed comment + Missing '}')。已改写为不含 /* 的说法。

版本名保持 1.5.1(按用户要求撤回后重发同一版本),versionCode 32 → 33(保证可覆盖升级)。
签名证书不变。测试 8 例全通过。
2026-09-26 22:33:36 +08:00
zijunchiang fad2599e63 V1.5.1: 修复"点歌最后一首按切歌→播完卡死",并按要求改为转入公放
现象: 点歌清单只剩最后一首且正在播放时按一下「切歌」,那首歌播完后 App 直接卡死
(之后按切歌/播放都没反应)。

根因(advancePlayback 的出口语义 + 僵尸态):
- orderQueue 是"已点清单",正在播的那首**仍在队列里**;按切歌时才移除。
- 旧逻辑: 显式切歌移除最后一首后队列已空 → 落到"清空到空闲态"分支,
  该分支把 currentSong 置空、playWhenPrepared=false,**却没有停播放器**。
- 于是那首歌继续播到结束;而 onCompletion 第一行是
  `if (generation != playbackGeneration || currentSong?.equals(song) != true) return`
  → currentSong 已为 null,回调被**直接吞掉**,再也不推进。
- 结果就是"音频结束 / currentSong 为空 / 播放中标志为 false"的僵尸态:
  再按切歌或播放都没有任何反应(用户看到的卡死)。

修复(完全按用户要求的状态机):
- 显式切歌且点歌清单已空(= 在最后一首上按切歌)→ **转入公放**(playRandomLocalSong(skipped)),
  不再清空到空闲态。公放清单与点歌清单互不影响:随机歌曲不入 orderQueue;
  用户一点歌,挂起的随机请求会被 orderQueue.isNotEmpty() 守卫取消。
- 其它出口保持原逻辑不变(队列还有歌→下一首;自然播完+autoNext+队列空→公放;
  手动模式自然播完→空闲)。
- 僵尸态兜底: 所有"清空播放状态"的出口都补上 `player.stopPlayback()` + currentMediaPlayer = null
  (空闲态分支、公放库为空的兜底分支)。唯一例外是马上 play() 下一首的路径——
  故意不停,避免切歌瞬间因曲库扫描而静音。

AGENTS.md 增补「切歌/续播状态机」四条出口 + 僵尸态的成因与强制约定。

版本 1.5.0(31) → 1.5.1(32);签名证书不变。测试 8 例全通过。
2026-09-26 21:47:12 +08:00
zijunchiang eadc2d4de1 V1.5.0: 修复"公放切歌失效"——上一版清空歌曲缓存把公放曲库删了(新设备回归)
现象: 新设备公放时按「切歌」没有任何反应。

根因(我上一版引入的回归):
- 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 = <major>.<minor>.<patch>,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 例全通过。
2026-09-26 21:25:37 +08:00
zijunchiang 669766ba60 V1.4.10: 修复广告过滤失效(gz.ac16.vip 广告被当成歌曲下载播放)
现场事故(新设备 SM-S9180 / Android 16,WebView 正常 → 走 JS 取址):
  277  JS [KtvApi] URL OK: 7303235 ls=1 via http://w.w345.my
  278  点歌编号=7303235 标题=情歌(HD) 请求源=http://w.w345.my 状态=成功 urlHost=gz.ac16.vip
  279  PLAY id=7303235 title=情歌(HD) src=local
即 gz.ac16.vip 的广告视频被当成歌曲下载、缓存并播放。

三处漏洞叠加:
1. ktv_api.js 的 isDemoUrl 不认识广告域名;且"ts 文件名与编号比对"用的是
   /\/(\d+)\.ts/,只匹配纯数字名 —— my_ad_video.ts 这类非数字名字直接放行;
2. SongOkDownloadManager.buildDownloadUrl 的**成功分支完全没有广告校验**
   (AdVideoFilter.isAdVideo 全项目只在"缓存地址"与"原生解析"两处被调用);
3. SongApiClient.fetchFromDb 只做 isLikelyDemoUrl(纯 IP / r2.dev),挡不住广告域名。
且**内容校验挡不住广告**: 该广告通过 SongFileValidator(≥6MB/≥60s/TS 包完整),
被 isDownloaded() 一直复用,广告会反复播。

修复(单一闸门,全链路把关):
- AdVideoFilter 新增 isTrustedSongUrl(url, musicno):非 http(s)、命中广告特征
  (路径 /ad_files/、my_ad_video、**域名 *.ac16.vip**)、或 URL 里 .ts 文件名不等于
  点歌编号 → 一律不可信。真源恒为 /<musicno>.ts(已实测 txog.ktvsky.com 与
  download.origjoy.com 两个 CDN 域共 6 首全部相符)。
- 该闸门接入全部出入口: JS isDemoUrl(同步加固 + KTV_BRIDGE_API 8→9)、
  SongUrlResolver.isValidSongUrl、SongApiClient.fetchFromDb、
  以及最终兜底 SongOkDownloadManager.buildDownloadUrl(成功/缓存两条分支)。
- 判定不可信时**同时删除已落盘的歌曲文件**(purgeCachedSongFile),
  否则 isDownloaded() 会一直复用那个广告文件。
- 新增 SongOkDownloadManager.purgeLegacySongCacheOnce(): 升级后一次性清理历史遗留的
  可疑歌曲缓存(marker: filesDir/song_cache_purged_v1)。只是让歌曲重新下载,不动用户数据。
- 新增回归测试 AdVideoFilterTest(6 例,用日志里的真实 URL 形态,含两个真源 CDN 域);
  该测试当场抓到本修复自身的一个 bug:正则把扩展名也捕获进去导致真源被误杀,已修正。

版本 1.4.9(29) 到 1.4.10(30);签名证书不变。
2026-09-26 21:17:04 +08:00
zijunchiang b3b17210f1 V1.4.9: 修复启动被误判为"断网恢复"(并补齐 WebView 失效原因取证)
现场日志(1.4.8)显示启动瞬间就出现:
  20:46:59.636  NET-UP 设备网络已恢复, 重建歌曲源会话
  20:46:59.647  NET-REFRESH reason=net-up retried=0
  20:46:59.980  BOOT
即 NET-UP 出现在 BOOT 之前 —— 启动被当成了"断网后恢复"。

根因: KtvNetworkMonitor.start() 末尾无条件 onChange(initial),
注册 ConnectivityManager 回调时系统会回报一次初始状态,上层把它当成了状态跃迁。
而启动流程(onCreate)已经做过 startRemoteServer()/updateMobileQrOverlay()/
refreshLogDeviceContext()/DeviceMac 解析,所以这次"恢复"是纯粹重复劳动,且有副作用:
- SongApiClient.resetSourceSession() 白清掉刚建立的歌曲源会话;
- Eac19WindowGuard.noteUrlOk() 语义错误(还没取到任何地址就标记窗口正常,
  可能掩盖真正的歌曲源窗口到期提示);
- 状态栏被写成"网络已恢复"(启动瞬间就显示,用户可见的错误文案)。

修复:
- KtvNetworkMonitor.start/refresh 的回调签名改为 (online, initial),
  initial=true 表示"注册时的初始状态";
- MainActivity.onNetworkStateChanged(online, initial): 初始状态**只更新断网角标**,
  不做任何恢复动作;恢复动作(重建会话/重启点歌服务/刷新二维码/重试下载)
  只在真正的 offline→online 跃迁时执行。

同时补齐 WebView 失效原因的取证(旧日志只有"KtvApi 未导出"这一句,无法定因):
- wrapHtml 注入特性探测: 上报 WEBVIEW ua=... asyncFunction=yes|no promise=...
  (用 new Function 探测,避免探测语句自身引起语法错误)。Android 5.x 的 WebView
  可能是 Chrome 39~4x,不支持 async/await 会让**整个 ktv_api.js 语法报错** →
  window.KtvApi 未定义、桥永远不就绪,这条日志可直接确证;
- window.onerror 上报脚本解析/运行错误(JS-ERROR ... @line:col),语法错误也会触发;
- 合并重复的失败行: "JS-BRIDGE 初始化返回 false" 与
  "JS-BRIDGE 判定本机 WebView 不可用" 只保留后者(含原因)。

版本 1.4.8(28) 到 1.4.9(29);签名证书不变。ktv_api.js 未改动,KTV_BRIDGE_API 仍为 8。
2026-09-26 21:00:21 +08:00
zijunchiang 906fa210e4 V1.4.8: 按现场日志复盘优化取址等待与远程日志(含 15 秒白等修复)
依据用户提供的 1.4.7 现场日志(21 行,5 次点歌全部成功)复盘出四点:

1. 【行为,最大】每次点歌白等 15 秒。
   日志里每首歌前都有一条「JS-BRIDGE 15s 未就绪」,紧跟着「NATIVE-SONG 取址成功 用时=3xx~7xxms」——
   即取址本身只要 0.3~0.7 秒,但用户从按下点歌到开始下载要等 15.7 秒。
   原因: 桥一旦不可用,SongApiClient 每次都仍等满 15 秒。
   修复: KtvJsBridge 新增「已判定不可用」状态(markUnavailable/isUnavailable),
   首次判定后后续点歌直接走原生通道;桥万一恢复(热更新重载成功)自动清除该状态。

2. 【日志去噪】持久状态只记一次。
   原来「15s 未就绪」按每次点歌一条 WARN 输出,一天几百条且无新增信息。
   现在只在状态首次变化时记一条 ERROR。

3. 【可定位】补上"为什么不可用"。
   旧日志里没有任何 KtvApi 未导出/WebView 初始化失败的行,说明 init 回调根本没触发。
   KtvJsBridge.init 增加 20 秒看门狗: onPageFinished 未回调时明确上报
   「初始化超时:onPageFinished 未回调或 KtvApi 未导出」;
   init 异常与 KtvApi 未导出也统一收敛到 markUnavailable。

4. 【上下文完整】启动关键行不再有 undefined。
   旧日志的 STORAGE 行 serverip/clientip/user/traceid 全是 undefined(记在设备上下文之前),
   而且 AppPaths.storageFacts() 会真实建目录、写探针、读 /proc/mounts —— 却跑在主线程。
   现在把 STORAGE 移入 reportDeviceBootInfo() 的后台线程、在 refreshLogDeviceContext()
   与 MiliLog.awaitServerIp() 之后上报: 上下文完整且不再阻塞主线程。

5. 【新增状态样本】每 6 小时一条 HEALTH(版本/在线/可用空间/曲库是否存在及大小/根目录)。
   日志此前只有事件没有状态样本,无法排查"随时间变化"的问题(空间变少、曲库丢失、长期离线)。

6. 【字段一致】「请求源=」不再为空: 走原生兜底时 JS 的 reportSongResult 不会触发,
   现在 SongUrlResolver 记录 lastHost,SongApiClient.lastSongSourceHost() 依次回退。

AGENTS.md 增补「写日志的五条约定」(只记一次/上下文完整/不做重活/可自我定位/状态样本+事件)。

版本 1.4.7(27) 到 1.4.8(28);签名证书不变。ktv_api.js 未改动,KTV_BRIDGE_API 仍为 8。
2026-09-26 20:38:52 +08:00
zijunchiang a7a28fc45d docs(AGENTS): 记录取址双实现(JS 主路径 + SongUrlResolver 原生兜底)的同步约定 2026-09-26 17:08:51 +08:00
zijunchiang 1687f87eca V1.4.7: 新增原生取址兜底 —— 修复 WebView 不可用的老盒子无法点歌
证据(用户导出的 1.4.6 远程日志):
- 两条失败记录只有 Kotlin 侧那一行
  「点歌编号=... 详情=无法生成下载地址」,
  而 JS ...(本版新加的 console 桥)、RAW-ERR、NET-ERR、点歌失败 一条都没有。
- 即 JS 侧从未执行 —— 隐藏 WebView 在这台盒子上从未就绪
  (每条失败前正好有约 15s 的「等桥就绪」超时,时间吻合)。
- 于是 SongApiClient 只能回退 DB 的 cloud_url,取不到就报「无法生成下载地址」。

根因: 取址完全依赖 WebView 里的 ktv_api.js;GMUI2.0/Android 5.1.1 上
WebView 不可用(无法导出 window.KtvApi),桥永远 not ready。

修复:
- 新增 SongUrlResolver: 用 Kotlin 复刻 ktv_api.js 的协议
  (GET <host>/i.php 取 token → GET <host>/music/do.php 取地址),
  镜像多源排序(失败源沉底)与 isDemoUrl 校验(wb66/demo/纯IP/r2.dev/ts编号不一致)。
- 走与 JS 完全相同的裸 Socket「干净请求」通道(KtvJsBridge.rawCleanGet),
  请求特征一致故风控表现一致,且不依赖 WebView 是否就绪。
- SongApiClient.getSongDownloadUrl 三处回退点改为:
  桥未就绪 / JS 未取到地址 / 主线程调用 → 先走原生解析,再回退 DB。
- 补齐桥的生命周期可观测性: WebView 初始化失败、KtvApi 未导出、
  15s 未就绪 均写入远程日志(以前只在 logcat,这就是本轮一开始没有线索的原因)。

已用 App 的真实身份( mac=800b510bf4fd / sn=2d7bb4a7d7f7 / VER=2.0 )
从本机复现协议: i.php 200 → do.php code=200 且返回
  http://txog.ktvsky.com/.../480p/7303235.ts (与点歌编号一致),
证明参数顺序与签名算法正确。

版本 1.4.6(26) 到 1.4.7(27);签名证书不变。ktv_api.js 未改动,KTV_BRIDGE_API 仍为 8。
2026-09-26 17:08:38 +08:00
zijunchiang 691e56f617 docs(AGENTS): 补充裸 Socket 通道三坑与 JS 日志桥接约定 2026-09-26 17:00:12 +08:00
zijunchiang 0cffd08789 V1.4.6: 点歌取址失败——修裸 Socket 通道两处缺陷 + 打通 JS 侧日志
现象: 曲库正常后点歌失败「详情=无法生成下载地址」,且远程日志里没有任何线索。

排查(本地复现源站协议):
- 用同一套签名从本机请求 i.php / music/do.php 全部正常,能拿到这首歌的真实地址;
  换 App 注入的版本号(VER=2.0/VN=4.1.3.03281430) 也正常 → 源站没问题,是设备侧。
- 根因在 KtvJsBridge.rawRequest() 这条裸 Socket 通道(点歌取址正是走它):
  1. 连接阶段没有超时: `Socket(host, port)` 不受 soTimeout 约束,遇路由黑洞按系统
     TCP 超时(~2min)干等;7 个源叠加远超 JS_REQUEST_TIMEOUT_MS(180s) →
     桥返回空 → 「无法生成下载地址」,而日志里连失败都没有(请求还没失败);
  2. HTTPS 分支用 SSLSocketFactory.getDefault(),既不受
     HttpsURLConnection.setDefaultSSLSocketFactory 影响,也不受
     network_security_config 约束 → 老设备缺 ISRG 新根时握手必失败。

修复:
- rawRequest 连接改为显式 connect(timeout): Socket().connect(InetSocketAddress, t);
  HTTPS 改用 TlsCompat.sslSocketFactory()(系统信任 ∪ 内置根证书)。
- TlsCompat 暴露 sslSocketFactory(),并按 API 分级: 裸通道始终用增强工厂,
  HttpsURLConnection 默认工厂仅在 API<24 接管。
- 裸通道失败上报 RAW-ERR(原来只在 logcat)。
- 打通 JS 侧日志: wrapHtml 注入 console 桥,把 ktv_api.js 的 _log()/console.log
  转发到 android.log → MiliLog("JS ...");点歌失败时可通过
  「JS ...」逐步过程 + 「点歌失败 ... 详情=」汇总原因定位。

版本 1.4.5(25) 到 1.4.6(26);签名证书不变。ktv_api.js 未改动,KTV_BRIDGE_API 仍为 8。
2026-09-26 16:59:43 +08:00
zijunchiang 1ceba8ef7f V1.4.5: 修正下载失败的错误归因 + 清理残留临时文件(为定位"空间不足"误报)
现象: 极米 H1S 上报告「曲库分区空间不足(当前可用 6.04 GB)」——数字自相矛盾。

核实(本地实测 Muse 协议):
- 曲库压缩包 430,575,974 字节;从 gzip 尾部 ISIZE 读出解压后为 965MB,
  峰值占用约 1.4GB,6.04GB 可用空间根本不构成空间不足。
- 结论: 真正抛出的异常另有原因(消息里含 no space left),
  被 isNoSpaceError() 只按消息文本匹配而误判成了"空间不足"。

修复:
- readableError 不再只看异常消息: 报告完整异常链 + 可用空间,
  并用真实写入探针(AppPaths.probeWrite,写 8MB 再删)区分
  "真的写不进去" 与 "其实能写"(inode 耗尽/配额/ROM 限制同样报 ENOSPC,
  而 usableSpace 仍显示数 GB)。
- 失败时上报单行完整诊断到远程日志:
  DB-SYNC download failed | <异常链> | root/free/total/envState/probe/proc-mounts。
- 界面失败文案直接显示原始异常,不再用可能误导的"空间不足"结论。
- AppPaths 新增 probeWrite() 与 diagnose()(含 /proc/mounts 相关挂载点)。
- MuseDbSync 失败时同时清理 muse.db.gz.download 与 muse.db.download:
  原先失败只删前者,残留的 965MB 分片在反复重试时会白占空间。

版本 1.4.4(24) 到 1.4.5(25);签名证书不变。
2026-09-26 16:42:59 +08:00
zijunchiang a467719675 V1.4.4: 修复曲库下载期间界面卡死/极慢(高频进度回调压死主线程)
现象: 极米 H1S 上曲库开始下载后速度极慢,并有概率卡死(ANR)。

根因: MuseDbSync.downloadFile 每读一个 256KB 分块就回调一次进度,
430MB 压缩包 = 约 1700 次回调;每次回调都 main.post -> showDatabaseLoading(),
而该函数每次刷新都在主线程做:
  setText + 改 ProgressBar + setOnClickListener(null) + bringToFront()
(bringToFront 会让整棵视图树 requestLayout)。
1700 次全屏布局把主线程 Looper 队列压满 -> 界面停止响应(卡死);
同时整机 CPU 被布局任务吃掉,网络线程与 FUSE 写盘都被拖慢 -> 下载本身也变慢。
两个症状同一个根因,不是网络问题。

修复:
- 进度回调两级去重: MuseDbSync 按百分比去重; MainActivity 按最终显示文案去重,
  刷新次数从约 1700 降到不超过 100。
- showDatabaseLoading 更新路径只做真正变化的事: 文本不同才 setText、进度不同才改
  progress、不再每次 bringToFront()、不再每次重设监听。
- 解压(约 1GB)与 md5 校验(430MB)阶段补上进度明细(例如 正在解压曲库 512MB),
  避免界面长时间停在某个百分比被误判为卡死。
- 下载速率按每 10% 上报远程日志: MUSE-DB progress 30% 129MB/430MB 1.85MB/s,
  可直接区分网络慢还是磁盘慢。
- 回调签名 (Int)->Unit 扩展为 (Int, String?)->Unit 以携带明细文案。
- AGENTS.md 增补 高频回调不能直接刷 UI 条目。

版本 1.4.3(23) 到 1.4.4(24);签名证书不变。
2026-09-26 16:23:46 +08:00
zijunchiang 62216b0fb8 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);签名证书不变。
2026-09-26 16:06:27 +08:00
zijunchiang 8ad2aa50d7 V1.4.2: 修复极米 H1S(Android 5.1.1) 一打开就闪退 —— 存储根目录写死导致
真正根因(用崩溃诊断包在该机上逐步定位得出,非此前推测的 API 守卫问题):
- AppPaths 把存储根写死成 /storage/emulated/0/MaidongKTV,该机上此路径不可写,
  ensureDirectories() 里 check(it.exists() || it.mkdirs()) 抛异常 →
  onCreate 中断 → App 一打开就闪退(该机历来所有版本均如此)。
- 定位过程: 逐条语句打启动轨迹 + 把异常直接写进轨迹通道,最终锁定
  「oc431 之后、oc432 之前」= AppPaths.ensureDirectories();包上 try/catch 后
  App 立即可正常打开,确认根因。

修复:
- AppPaths 改为运行时解析可写根目录: Environment 外部根 → 历史硬编码路径 →
  getExternalFilesDir() → filesDir,取第一个「可建且可写」的(幂等 init)。
- AppPaths.init(context) 在 KtvApplication.attachBaseContext 最前面调用,
  早于任何 AppPaths 访问;路径成员由 val 改为 getter。
- ensureDirectories() 不再抛异常,返回 Boolean;MainActivity 拿到 false 只告警继续。
- 去掉编译期内联的路径常量: MuseDatabase.VIDEO_ROOT(const→getter)、
  defaultDbFile()/findDatabaseFile() 改用 AppPaths.databaseFile
  (否则改存储根后 DB 仍指向硬编码路径)。
- 用户可见的目录提示文案改用 AppPaths.*Dir.absolutePath。
- 启动上报一行 STORAGE root=.. by=.. envState=.. probe=[..] 到远程日志,
  便于排查不同盒子的存储差异。

验证:
- DEX 中「内联的完整 DB 路径」由 1 处 → 0 处。
- 单元测试 2/2 通过;lint NewApi 闸门通过;签名证书不变(可覆盖 1.4.0/诊断包)。
- AGENTS.md 增补「存储根目录必须运行时解析」条目。

说明: versionCode 18~21 被本机临时诊断包占用,本版取 22 以便直接覆盖安装。
2026-09-26 15:53:22 +08:00
zijunchiang 40d1fec719 V1.4.1: 修复 Android 5.1.1 设备一打开就闪退(API 守卫缺失)
根因(极米 H1S / MStar 6A928 / GMUI2.0 / Android 5.1.1):
- MainActivity 把 API 23 才有的 android.media.AudioDeviceCallback 写成了
  **字段初始化器**(private val micDeviceCallback = object : ...)。
  字段初始化在 Activity 构造期执行,早于 onCreate 里的 SDK_INT 判断,
  于是下面的 registerMicInputWatcher() 版本守卫形同虚设 ——
  构造 MainActivity 即抛 NoClassDefFoundError,表现为一打开就闪退。
- 顺带修复 36 处同类未守卫的高版本 API 调用(lint NewApi 全量清零):
  * AudioDeviceCallback / AudioDeviceInfo.isSource/getType (API 23)
    → 改为可空字段 + 仅在 SDK_INT>=23 分支按需创建(@TargetApi)
  * HashMap.putIfAbsent ×5 (API 24) → MutableMap.getOrPut
  * Comparator.comparing ×3 + comparingInt ×1 (API 24) → Kotlin compareBy
    (同时移除 java.util.function 导入)
  * ConcurrentHashMap.newKeySet ×2 (API 24) → Collections.newSetFromMap
    (连带 16 处 KeySetView.add/remove/contains)
  * ConcurrentHashMap.computeIfAbsent (API 24) → ConcurrentHashMap.putIfAbsent(API 1)
  * URLConnection.getContentLengthLong ×5 (API 24) → 新增 Compat.contentLengthCompat()
  * ByteArrayOutputStream.toString(Charset) (API 33) → String(bytes, UTF_8)

防回归:
- app/build.gradle 打开 lint 闸门: checkReleaseBuilds true + abortOnError true +
  fatal += NewApi(并 disable ExpiredTargetSdkVersion,避免它挤占闸门)。
  已实测: 植入一个 NewApi 违规后 assembleRelease 直接 FAILED。
- AGENTS.md 新增「低版本设备闪退(API 守卫)」条目,记录根因、替代写法与自查命令。

版本号 1.4.0(16) → 1.4.1(17);签名证书不变,可覆盖安装。
2026-09-26 13:59:51 +08:00
zijunchiang 96c83d27b0 docs: 修复凭据 helper 作用域与令牌提取(发布时踩到的 401 根因)
- 仓库级 credential.helper(无主机限定) 会让 git 在 Gitee 认证成功后调用 store,
  把 Gitee 凭据追加写进 ~/.git-credentials-gitea(一行变两行),
  此后按单行提取的令牌变成两行拼接值,Gitea API 报 401
- 改为按主机限定的 credential.https://gitea.kasugano.cn.helper,
  并把发布小节的 TOKEN 提取改成带主机锚点的 sed -nE ... p
- 新增「发布新版本(Gitee Release)」小节:建 Release 与 attach_files(字段名 file)
  两个 API 写法、中文附件名可用、发布后必须校验下载 md5
- 纯文档改动,未改 App 代码,版本号保持 1.4.0
2026-09-26 12:52:05 +08:00
20 changed files with 1703 additions and 169 deletions
+241 -5
View File
@@ -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` 形如 `<major>.<minor>.<patch>`,
**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**。
@@ -34,11 +39,16 @@
- 凭据:
- **Gitea**(代码主仓库):访问令牌 `dsh-sync-20260926`(2026-09-26 数据迁移后重建;可在 Gitea
「设置 → 应用 → 访问令牌」随时吊销),存放于 `~/.git-credentials-gitea`(权限 600),
由本仓库级 `credential.helper` 引用;
由**按主机限定**的仓库级 `credential.https://gitea.kasugano.cn.helper` 引用;
- **Gitee**(仅发布 Release,按规则 2 不推代码):访问令牌存放于 `~/.git-credentials-gitee`
(权限 600),由全局 `credential.https://gitee.com.helper` 引用;**只用于 API 建 Release /
传附件**,不得用于推送源码。
- 账号密码不写入仓库配置。
- ⚠️ **两个 helper 都必须按主机限定作用域**(`credential.https://<host>.helper`,不要用无主机限定的
`credential.helper`):git 认证成功后会调用**每个** helper 的 `store`,无主机限定会把 Gitee 凭据
追加写进 Gitea 的凭据文件(一个文件两行),此后「取第一行」式的令牌提取会拿到错误令牌、API 报 401。
- ⚠️ **提取令牌必须按主机过滤**:用 `sed -nE 's|https://[^:]+:([^@]+)@<host>.*|\1|p'`,
不要用不带主机锚点的 `sed -E 's|https://[^:]+:([^@]+)@.*|\1|'`(多行文件会输出多行、拼成错令牌)。
### 推送方式
@@ -63,7 +73,7 @@ App 内部两处端点已指向 Gitea(随本版 APK 生效):
### 发布新版本(Gitea Release)
```bash
TOKEN=$(sed -E 's|https://zijunchiang:([^@]+)@.*|\1|' ~/.git-credentials-gitea)
TOKEN=$(sed -nE 's|https://[^:]+:([^@]+)@gitea\.kasugano\.cn.*|\1|p' ~/.git-credentials-gitea)
# 1) 创建 Release(tag 需高于当前 versionName 才会提示更新)
curl -X POST -u "zijunchiang:$TOKEN" -H "Content-Type: application/json" \
@@ -84,6 +94,26 @@ curl -X POST -u "zijunchiang:$TOKEN" \
>
> 上传 22MB APK 约需 70s,注意命令超时设置。
### 发布新版本(Gitee Release)
```bash
GT=$(sed -nE 's|https://[^:]+:([^@]+)@gitee\.com.*|\1|p' ~/.git-credentials-gitee)
# 1) 创建 Release(Gitee 会自动创建同名 tag)
curl -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" \
-d '{"tag_name":"v1.4.0","name":"麦动KTV V1.4.0","body":"更新说明","target_commitish":"master"}' \
https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases
# 2) 上传 APK 附件(表单字段名是 file,不是 attachment;用返回的 release id)
curl -X POST -H "Authorization: token $GT" \
-F "file=@麦动KTV-V1.4.0-release.apk" \
"https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/<id>/attach_files"
```
> Gitee 附件名**可用中文**(ASCII 限制只针对 Gitea 1.27),上传 22MB 约 4s。
> 发布后务必校验下载:`curl -sL <browser_download_url> | md5sum` 应与本地发布包一致
> (Gitea 侧同样要校验 `/releases/download/<tag>/<文件>` 规范路径返回 200)。
## 二、构建与产物
### 构建(release,双 ABI)
@@ -135,13 +165,159 @@ $BT/apksigner verify 目标名.apk
### 版本号
- `app/build.gradle`:`versionName "1.4.0"` / `versionCode 16`
- `app/build.gradle`:`versionName "1.5.1"` / `versionCode 33`(18~21 被本机临时诊断包占用过)
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
被阻断时按报错位置补版本判断或改用兼容写法,**不要**把 `checkReleaseBuilds` 改回 `false`
来绕过 —— 这道闸门专为「低版本设备一打开就闪退」而设(见第三节同名条目)
## 三、关键实现约束(避免踩坑)
- **⚠️ 低版本设备闪退(API 守卫)**:`minSdk 21`,但**实际客户里有 Android 5.1.1 的机器**
(极米 H1S / MStar 6A928 / GMUI2.0)。以下三类写法会让 app 在 API 22 上**一打开就闪退**,
必须避免:
1. **把高版本 API 的类写在 `Activity`/`Fragment`/`object` 的字段初始化器里** ——
字段初始化在**构造期**执行,早于 `onCreate` 里的 `SDK_INT` 判断,因此守卫形同虚设。
v1.4.0 的闪退根因就是 `private val micDeviceCallback = object : android.media.AudioDeviceCallback() {...}`
(`AudioDeviceCallback` 是 API 23)→ 构造 `MainActivity` 时 `NoClassDefFoundError`。
正确写法:字段声明为可空、在 `SDK_INT >= 23` 分支里**按需创建**(见 `ensureMicDeviceCallback()`)。
2. **Java 8 的集合默认方法**:`Map.getOrDefault` / `Map.putIfAbsent` / `ConcurrentHashMap.newKeySet`
/ `computeIfAbsent` / `Comparator.comparing` / `List.sort` 等都是 **API 24+**,
API 21~23 上抛 `NoSuchMethodError`。替代:`getOrPut`、
`Collections.newSetFromMap(ConcurrentHashMap())`、`ConcurrentHashMap.putIfAbsent`(API 1)、
Kotlin `compareBy`。
3. 其它漏网的:`URLConnection.getContentLengthLong()`(API 24,用 `Compat.kt` 的
`contentLengthCompat()`)、`ByteArrayOutputStream.toString(Charset)`(API 33)。
- 自查命令(必须为 0 条):`./gradlew :app:lintRelease` 后
在 `app/build/reports/lint-results-release.txt` 里搜 `NewApi`。
- 根因扫描捷径:`grep -n "private val .* = object : android\." app/src/main/java/com/local/ktv/*.kt`
—— 字段初始化器里出现 `android.*` 的高版本类就是要闪退。
- **⚠️ 存储根目录必须运行时解析(不要再写死 `/storage/emulated/0`)**:
`AppPaths` 曾把根目录写死成 `/storage/emulated/0/MaidongKTV`,在**极米 H1S(GMUI2.0 / Android 5.1.1)**
上该路径不可写 → `ensureDirectories()` 的 `check(it.exists() || it.mkdirs())` 抛异常 →
**App 一打开就闪退**(这台机器历来所有版本都打不开的真正原因,2026-09-26 定位)。
现在的约定:
1. `AppPaths.init(context)` 在 `KtvApplication.attachBaseContext` **最前面**调用,按
「`Environment.getExternalStorageDirectory()` → 历史硬编码路径 → `getExternalFilesDir()`
→ `filesDir`」顺序挑**第一个真正可建且可写**的目录(幂等);
2. 所有用到根路径的地方必须走 `AppPaths.*` 的 **getter**,**绝不能用 `const val` 把路径编译期内联**
(`MuseDatabase.VIDEO_ROOT`、`MuseDatabase.defaultDbFile()` 曾经就是 `const val` 内联的,
改存储根后必须一起改成运行时取值);
3. 目录创建**不许抛异常**:`AppPaths.ensureDirectories()` 返回 `Boolean`,失败只记日志,
`MainActivity` 拿到 false 也只是告警继续,绝不能让存储问题变成闪退;
4. 用户可见的目录提示文案也要用 `AppPaths.*Dir.absolutePath` 拼,不要写死 `/sdcard/...`;
5. 启动时会往远程日志打一行 `STORAGE root=... by=... envState=... probe=[...]`,
换盒子/换 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,界面只剩
「未找到可用曲库」兜底文案,排查极其困难。
- **⚠️ 高频回调不能直接刷 UI(曲库下载 = 430MB)**:`MuseDbSync.downloadFile` 每读一个
256KB 分块回调一次进度,430MB 就是 **约 1700 次**。若每次回调都 `main.post { 改文本 +
改进度 + bringToFront() }`,主线程 Looper 队列会被压满 —— 实测表现为**界面卡死、进度停滞**,
而且整机 CPU 被布局任务吃掉后**下载本身也变慢**(极米 H1S 上就是这个现象)。
约定:
1. 进度回调必须**按最终显示文案去重**(`MuseDbSync` 侧按百分比去重、
`showDatabaseDownloadProgress` 侧按文本去重),刷新次数上限 ~100 次;
2. `showDatabaseLoading()` 的更新路径**只做真正变化的事**:文本不同才 `setText`、
进度不同才 `progress=`、**不要每次 `bringToFront()`**(整棵树 `requestLayout`)、
不要每次重设 `setOnClickListener`;
3. 解压(约 1GB)与 md5 校验(430MB)阶段**也要报进度**(回调带明细文案,
如「正在解压曲库 512MB」),否则界面会长时间停在某个百分比、看着像卡死;
4. 下载速率会按每 10% 上报一次 `MUSE-DB progress 30% 129MB/430MB 1.85MB/s`,
判断"是网络慢还是磁盘慢"直接看这几行;
5. 新增任何"周期性/高频"回调(进度、心跳、轮询)时都要按同样标准评估主线程开销。
- **⚠️ 裸 Socket 通道(`KtvJsBridge.rawRequest`)的三个坑**:点歌取址走的正是这条通道
(为了贴近原版风控要求的"干净请求"),它**不受** `HttpsURLConnection` 默认工厂与
`network_security_config` 的任何影响:
1. **HTTPS 必须用 `TlsCompat.sslSocketFactory()`**,不能用 `SSLSocketFactory.getDefault()`——
后者是系统信任库,老设备(及 Android 7.0)没有 ISRG 新根,握手直接失败;
2. **连接必须显式给超时**:`Socket(host, port)` 的连接阶段**不受** `soTimeout` 约束,
遇路由黑洞会按系统 TCP 超时(约 2 分钟)干等;多源叠加会耗光 JS 侧
`JS_REQUEST_TIMEOUT_MS`(180s) 预算,表现为**「无法生成下载地址」且日志里什么都没有**。
正确写法:`Socket().connect(InetSocketAddress(host, port), connectTimeout)`;
3. **失败必须上报**:裸通道失败打 `RAW-ERR`、HttpURLConnection 回退失败打 `NET-ERR` 到远程日志。
- **JS 侧日志必须能进远程日志**:`ktv_api.js` 的 `_log()` 走 `console.log`(桥以 `debug:true` 实例化),
默认只进 WebView 控制台、现场无法取证。`KtvJsBridge.wrapHtml()` 已注入 console 桥把
`console.log` 转发到 `android.log` → `MiliLog.info("JS ...")`。
点歌失败时先搜远程日志里的 **`JS `** 与 **`点歌失败`** 两行:前者给
「Try source / Token OK/FAIL / do.php code=」的逐步过程,后者给 JS 汇总的失败原因。
- **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底)
- **⚠️ 取址有两条实现,改协议必须同时改**:JS 主路径(`ktv_api.js`)+ 原生兜底
(`SongUrlResolver.kt`,供 **WebView 不可用**的老盒子使用 —— 实测极米 H1S/GMUI2.0 上
WebView 导不出 `window.KtvApi`,桥永远 not ready,只靠 JS 时表现为
**「无法生成下载地址」且远程日志里一条 `JS ...` 都没有**)。
- `SongUrlResolver` 镜像了 `ktv_api.js` 的 `APP_ID/APP_KEY/SDK_KEY/HOSTS` 与
**桥注入的** `VER=2.0/VN=4.1.3.03281430`(注意不是 JS 文件里的原始值)、
以及 `isDemoUrl` 校验(wb66/demo/纯 IP/r2.dev/ts 编号与 musicno 不一致)。
源站配置若变更,JS 能热更新而它必须发版 —— 所以它**只做兜底**:
`SongApiClient.getSongDownloadUrl` 在「桥未就绪 / JS 未取到地址 / 主线程调用」
三个回退点先试原生、再回退 DB 的 `cloud_url`。
- 它必须走 `KtvJsBridge.rawCleanGet()`(与 JS 完全相同的裸 Socket 干净请求),
否则会被源站风控拒绝。
- 自查远程日志:成功是 `NATIVE-SONG 取址成功 编号=... 源=...`,失败是
`NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch`;
桥侧另有 `JS-BRIDGE ...`(ready / KtvApi 未导出 / WebView 初始化失败 / 15s 未就绪)。
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
- **⚠️ 广告/错号地址只有一道闸门:`AdVideoFilter.isTrustedSongUrl(url, musicno)`**
(2026-09-26 事故:新设备点歌 7303235 拿到 `gz.ac16.vip` 的**广告视频并被当成歌曲下载播放**)。
约定:
1. **任何来源**的播放地址都必须过这道闸门 —— JS 取址(`ktv_api.js` 的 `isDemoUrl`)、
原生兜底(`SongUrlResolver.isValidSongUrl`)、DB 回退(`SongApiClient.fetchFromDb`),
以及**最终兜底** `SongOkDownloadManager.buildDownloadUrl` 的成功分支。
只加在某一层必然漏:这次就是 JS 层的规则不认广告域名、且 Kotlin 的成功分支**完全没做校验**;
2. 判定三条:非 http(s);命中广告特征(路径 `/ad_files/`、`my_ad_video`、**域名 `*.ac16.vip`**);
**URL 里的 `.ts` 文件名必须等于点歌编号**(真源恒为 `/<musicno>.ts`,已实测
`txog.ktvsky.com` / `download.origjoy.com` 两个 CDN 域)。旧规则只比对"纯数字文件名",
`my_ad_video.ts` 这类非数字名字直接漏过;
3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整),
所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件**
(`purgeCachedSongFile`),否则 `isDownloaded()` 会一直复用那个广告文件,广告反复播;
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/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
@@ -166,6 +342,23 @@ $BT/apksigner verify 目标名.apk
客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error`
保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、
设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率
- **写日志的五条约定**(2026-09-26 依据现场日志复盘补充):
1. **持久状态只记一次**,不要每次事件都记。反例:桥不可用曾按「每次点歌一条 WARN」输出,
一台机器一天几百条、把日志刷满且毫无新增信息。现在由 `KtvJsBridge.markUnavailable()`
在**状态首次变化**时记一条。
2. **关键行必须带全上下文**:`STORAGE`/`BOOT` 这类启动行要放在 `reportDeviceBootInfo()` 的
后台线程里、`refreshLogDeviceContext()` 与 `MiliLog.awaitServerIp()` **之后**,
否则 `serverip`/`clientip`/`user`/`traceid` 全是 `undefined`(旧日志按设备检索不到)。
3. **不要在日志行里做重活**:`AppPaths.storageFacts()`/`diagnose()` 会真实建目录、写探针、
读 `/proc/mounts`,**绝不能放在主线程**(启动时曾如此)。
4. **日志要能自我定位**:失败行写清"哪一步/哪个源/耗时/原因",例如
`NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch 用时=...ms`;
桥侧由 `JS-BRIDGE ...` 说明 WebView 到底卡在哪一步(初始化失败/KtvApi 未导出/20s 超时)。
5. **状态样本 + 事件**缺一不可:事件(点歌/失败)说明"发生了什么",但排查"随时间变化"的
问题(空间变少、曲库丢失、长期离线)需要状态样本 —— 现由 6 小时一条 `HEALTH ...`
(版本/在线/可用空间/曲库大小/根目录)提供。
- 一段典型现场日志应为:`BOOT` → `STORAGE` → (点歌)`点歌编号=... 请求源=... 状态=成功 urlHost=...`
→ `PLAY ... src=local` → 每 6h `HEALTH ...`;异常时才有 `RAW-ERR`/`NET-ERR`/`NATIVE-SONG ...失败`。
- **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询)
维护全局 `online`;`MainActivity.onNetworkStateChanged()` 负责:
- 断网 → 播放页右上角 `fullScreenNetworkBadge` 显示「网络未连接」(在全屏控制层 header 内、
@@ -173,6 +366,13 @@ $BT/apksigner verify 目标名.apk
- 恢复 → `refreshNetworkState()`:重解析网卡 MAC + 日志上下文、**重建歌曲源会话**、
复位 `Eac19WindowGuard`、重启本地点歌服务并刷新二维码、自动重试断网期间失败的下载(最多 8 首)。
换网/换 IP 时 `remoteNetworkMonitor` 走同一个 `refreshNetworkState()`(静默,不弹提示)。
- ⚠️ **启动 ≠ 网络恢复**:`KtvNetworkMonitor.start()` 的回调带 **`initial`** 参数,
`initial=true` 表示"注册回调时系统回报的初始状态"。**绝不能**把它当成"断网后恢复"——
启动流程(`onCreate`)自己已经做过 `startRemoteServer()`/二维码/设备上下文,
再走一遍恢复逻辑会:白清刚建立的歌曲源会话、语义错误地 `noteUrlOk()`(掩盖真正的
窗口到期提示)、把状态栏错写成"网络已恢复"(启动瞬间就显示,用户可见)。
初始状态**只更新断网角标**,恢复动作只在真正的 offline→online 跃迁时执行
(2026-09-26 由现场日志 `NET-UP` 出现在 `BOOT` 之前而发现)。
⚠️ **「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因**:
`SongApiClient.clearTokenCache()` 原本是**空实现**,JS 侧 `window.api.tokens` 与
`_apiInitPromise` 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上,
@@ -253,6 +453,42 @@ $BT/apksigner verify 目标名.apk
`next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。
**不显示**(界面不展示)、**不关联已点清单**(`orderQueue` 完全独立,随机歌曲不入队)、
**App 退出即清理**(`onDestroy` 里 `isFinishing` 时 `clear()`,不落盘)
- **⚠️ 切歌/续播的状态机(`MainActivity.advancePlayback`)**:`orderQueue` 是"已点清单",
正在播的那首**仍在队列里**,切歌时才移除。四条出口,改动前先对齐语义:
1. **显式切歌**且队列还有歌 → 播下一首;
2. **自然播完** + `autoNext` + 队列已空 → 进公放(随机本地歌曲);
3. **显式切歌**且队列已空(= 在点歌清单**最后一首**上按切歌)→ **进公放**。
这是 2026-09-26 用户明确要求的行为:公放清单与点歌清单互不影响
(随机歌曲不入 `orderQueue`;用户一点歌,挂起的随机请求就会被
`orderQueue.isNotEmpty()` 守卫取消);
4. 手动模式(`autoNext=false`)自然播完 → 停在空闲态等待点歌(保持旧逻辑)。
⚠️ **凡是要清空 `currentSong`/播放状态的出口,都必须同时停播放器**
(`player.stopPlayback()` + `currentMediaPlayer = null`,参考「清空已点」处理)。
原因:`onCompletion` 第一行是
`if (generation != playbackGeneration || currentSong?.equals(song) != true) return`,
所以只要出现"**播放器还在跑、`currentSong` 已被置空**",那首歌播完时回调被直接吞掉:
不再推进、状态却是"无歌可播",之后按切歌/播放都没有反应 ——
2026-09-26「点歌最后一首按切歌 → 播完卡死」就是这个僵尸态。
唯一例外是"马上要 `play()` 下一首"的路径(可以不停,避免切歌瞬间静音)。
- **⚠️ 「切歌 → 公放」在老盒子上卡死(2026-09-26,极米 H1S)**:新设备正常、老设备卡死,
排查出**三个叠加原因**,都已修掉,改动这块时必须同时守住:
1. **不能共用 `io` 线程池**:`io` 只有 4 个线程,且"点歌即下即播"(`streamDownloadAndPlay`)
这类几十秒的长任务也在里面。老设备网速慢,并发点几首歌就把池占满,切歌任务一直排队。
→ 切歌/随机续播改用 `randomPlayExecutor`(专用单线程守护线程)。
2. **不能现扫目录找候选**:候选来自 `localSongInventory()` + `library.scanLocal()`,
代价是"每个文件在头尾各读约 4.3MB 做 TS 校验" + "遍历 `/storage/ 各挂载点`
(老 ROM 的失效挂载点会让 `File.listFiles()` **永久阻塞**)"。
→ 改为启动时后台**预热候选缓存**(`primeRandomCandidates()`:独立守护线程 +
`scanWithTimeout` 超时保护),下载完成即时补进缓存,切歌直接读缓存(零 I/O)。
3. **主线程不能做深度文件校验**:`Song.hasLocalFile()` 内部是 `SongFileValidator.inspect`
(头尾各读 4.3MB)。它曾被用在 `play()`、播放错误回调、`switchToLocalPlayback`,
以及**逐行渲染**的 `songRowState()` 里 —— 慢存储上每次都是秒级,直接 ANR。
→ 播放/渲染路径统一改用**廉价检查** `MainActivity.hasPlayableLocalFile()`
(只看存在与大小),深度校验只留在后台(`localSongInventory()` / `isDownloaded()`)。
- **⚠️ Kotlin 块注释会嵌套,注释里不能出现 `/*`**:写 `` `/storage/*` `` 时那个 `/*` 会开启一个
**嵌套注释**,导致 KDoc 永远不闭合,后面的代码全被吞进注释里(报错是
"Unclosed comment" + 一堆 "Missing '}'",很难一眼看出)。注释里要写路径通配就写
`/storage/ 各挂载点` 这种不含 `/*` 的说法。
- **弹窗分辨率自适应**:TV/投影/盒子的分辨率与 density 差异很大(1280x720、1920x1080,
density 1.0~2.5),固定 dp 尺寸在矮屏上会被屏幕裁切。统一用 `fitDialogToScreen(dialog, scroll,
content, preferredWidthDp, …)`:宽度取 `min(preferredWidthDp, 屏幕宽*0.94)`,高度把内容所在的
+14 -5
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
versionCode 16
versionName "1.4.0"
versionCode 33
versionName "1.5.1"
ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
@@ -52,10 +52,19 @@ android {
noCompress 'ts', 'mp3', 'mp4', 'aac', 'm4a', 'flac', 'wav'
}
// 关闭发布构建的 lint vital 检查(本地分发的 APK 不需要 Google Play 上架要求)
// lint 策略:
// - 「调用了高于 minSdk 且未做版本守卫的 API」(NewApi) 必须**阻断发布构建**:
// 这类问题在 Android 5.x / 6.x 上直接 NoSuchMethodError / NoClassDefFoundError 闪退,
// 而本地打包测试用的都是较新设备, 极容易漏出去(v1.4.0 在极米 H1S / Android 5.1.1
// 一打开就闪退, 根因就是 AudioDeviceCallback(API 23) 被写进了 Activity 的字段初始化器)。
// - ExpiredTargetSdkVersion 默认也是 fatal 级, 但本地分发的 APK 不上架 Google Play,
// 显式关掉, 否则会挤占下面这道闸门。
// - abortOnError 必须为 true: 设为 false 会把 lintVital 的失败一并吞掉, 闸门会失效。
lint {
checkReleaseBuilds false
abortOnError false
fatal += ['NewApi']
disable += ['ExpiredTargetSdkVersion']
checkReleaseBuilds true
abortOnError true
}
// 启用 BuildConfig 生成,供 AboutFragment 读取版本号/版本Code等信息
+11 -5
View File
@@ -12,7 +12,7 @@
(function(global) {
'use strict';
// KTV_BRIDGE_API: 8 (Android 缓存校验标记,请勿删除)
// KTV_BRIDGE_API: 9 (Android 缓存校验标记,请勿删除)
// ─── 配置 ──────────────────────────────────────────
const CONFIG = {
@@ -138,13 +138,19 @@
function isDemoUrl(url, musicno) {
var s = String(url || '');
if (/(?:wb66|demo)/i.test(s)) return true;
// 广告视频专用域名(含子域)。2026-09-26 事故:新设备(WebView 正常 → 走本 JS 取址)
// 点歌拿到了 gz.ac16.vip 的广告视频,而它通过了下述所有旧规则 → 被当成歌曲下载播放。
if (/^https?:\/\/(?:[^\/]+\.)?ac16\.vip(?::\d+)?\//i.test(s)) return true;
if (/\/ad_files\/|my_ad_video/i.test(s)) return true;
if (/^https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?\//i.test(s)) return true;
// R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源
if (/pub-[a-z0-9]+\.r2\.dev\/ss\//i.test(s)) return true;
// ts 文件名是纯数字, 若与请求的 musicno 不一致则视为广告/测试资源。
// 原版真源 URL 的 ts 文件名总是等于 musicno (如 /7120742.ts)。
// ts 文件名必须等于 musicno。原版真源 URL 恒为 /<musicno>.ts(已实测两个 CDN 域
// txog.ktvsky.com / download.origjoy.com 的 6 首歌全部如此)。
// ⚠️ 旧规则只比对"纯数字文件名"(/\/(\d+)\.ts/),像 my_ad_video.ts 这种非数字名字
// 会直接漏过 —— 这正是广告漏放的直接原因之一。
if (musicno) {
var m = s.match(/\/(\d+)\.ts(?:\?|$)/);
var m = s.match(/\/([^\/?]+)\.ts(?:\?|$)/i);
if (m && m[1] !== String(musicno)) return true;
}
return false;
@@ -435,7 +441,7 @@
// ─── 导出 ──────────────────────────────────────────
global.KtvApi = KtvApi;
global.KtvApiConfig = CONFIG;
global.KtvBridgeApiVersion = 8;
global.KtvBridgeApiVersion = 9;
if (typeof module !== 'undefined' && module.exports) {
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
@@ -21,6 +21,19 @@ object AdVideoFilter {
"my_ad_video",
)
/**
* 广告视频专用域名(命中即判定为广告,含其子域)。
*
* 2026-09-26 现场事故:新设备(WebView 正常 → 走 JS 取址)点歌 7303235 拿到了
* `gz.ac16.vip` 的广告视频,而且它**通过了内容校验**(≥6MB / ≥60s / TS 包完整),
* 于是被当歌曲缓存并播放。仅靠路径片段(`/ad_files/`、`my_ad_video`)不足以拦住 ——
* 广告方一改文件名就漏,因此必须**按域名**兜底。
*/
private val AD_HOSTS = listOf(
"gz.ac16.vip",
"ac16.vip",
)
/**
* 替换地址。为 null 时表示「屏蔽」(返回 null,由 DB 真实歌曲地址兜底);
* 设置后,命中广告时用该地址替换原广告。
@@ -29,14 +42,43 @@ object AdVideoFilter {
@JvmField
var replacementUrl: String? = null
/** 判断是否为广告视频地址。 */
/** 判断是否为广告视频地址(路径特征 + 域名)。 */
@JvmStatic
fun isAdVideo(url: String?): Boolean {
if (url.isNullOrEmpty()) return false
val lower = url.lowercase()
return AD_MARKERS.any { lower.contains(it) }
if (AD_MARKERS.any { lower.contains(it) }) return true
val host = hostOf(url) ?: return false
return AD_HOSTS.any { host == it || host.endsWith(".$it") }
}
/**
* 取址链路的**统一可信校验**(所有来源的地址都必须过这一关)。
*
* 判定为不可信的情况:
* 1. 非 http(s);
* 2. 命中广告特征([isAdVideo]);
* 3. **URL 里的 `.ts` 文件名与点歌编号不一致** —— 真源 URL 的文件名恒等于点歌编号
* (见 `ktv_api.js` 的说明),而广告文件常叫 `my_ad_video.ts`。
* 旧规则只处理"纯数字且与编号不同"(`/\/(\d+)\.ts/`),非数字文件名直接放行,
* 这正是本次广告漏放的直接原因。
*/
@JvmStatic
fun isTrustedSongUrl(url: String?, musicno: String?): Boolean {
if (url.isNullOrEmpty()) return false
if (!url.startsWith("http://") && !url.startsWith("https://")) return false
if (isAdVideo(url)) return false
val name = TS_BASENAME.find(url)?.groupValues?.getOrNull(1)
if (name != null && !musicno.isNullOrEmpty() && name != musicno) return false
return true
}
private fun hostOf(url: String): String? =
runCatching { java.net.URI(url).host?.lowercase() }.getOrNull()
/** 取 `.ts` 的**文件名主体**(不含扩展名),供与点歌编号比对。 */
private val TS_BASENAME = Regex("/([^/?]+)\\.ts(?:\\?|$)", RegexOption.IGNORE_CASE)
/**
* 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换);
* 非广告地址原样返回。
+154 -20
View File
@@ -1,35 +1,169 @@
package com.local.ktv
import android.content.Context
import android.os.Environment
import android.util.Log
import java.io.File
import java.io.FileOutputStream
/** The only external-storage namespace used by the fresh Maidong KTV app. */
/**
* 麦动 KTV 的存储命名空间。
*
* ⚠️ **历史实现把根目录写死成 `/storage/emulated/0/MaidongKTV`**,在部分定制 TV 盒子
* (极米 H1S / GMUI2.0 / Android 5.1.1 = API 22)上该路径不可写:`ensureDirectories()`
* 里 `check(it.exists() || it.mkdirs())` 失败抛异常,**App 一打开就闪退**。
*
* 现在改为**运行时解析**:按「设备真实外部存储根 → 历史硬编码路径 → App 私有外部目录 →
* 内部目录」顺序挑第一个真正**可创建且可写**的目录,保证目录一定建得出来,
* 曲库/下载/状态不因存储差异而不可用。
*
* 调用约定:`Application.attachBaseContext` 里**最先**调用 [init],早于任何 AppPaths 访问。
*/
object AppPaths {
private const val TAG = "AppPaths"
/** 历史路径(作为候选之一保留,并与老版本数据保持兼容)。 */
const val ROOT_PATH = "/storage/emulated/0/MaidongKTV"
const val VIDEO_PATH = "$ROOT_PATH/video"
val root = File(ROOT_PATH)
val stateFile = File(root, "state.json")
val songsDir = File(root, "songs")
val importDir = File(root, "import")
val catalogFile = File(root, "catalog.json")
val recordsDir = File(root, "records")
val moviesDir = File(root, "movies")
val appsDir = File(root, "apps")
val backupDir = File(root, "backup")
val databaseDir = File(root, "database")
val databaseFile = File(databaseDir, "muse.db")
val videoDir = File(VIDEO_PATH)
val cloudSongsDir = File(videoDir, "cloud-song")
@Volatile private var baseDir: File = File(ROOT_PATH)
fun ensureDirectories() {
listOf(root, songsDir, importDir, recordsDir, moviesDir, appsDir, backupDir,
databaseDir, videoDir, cloudSongsDir).forEach {
check(it.exists() || it.mkdirs()) { "Cannot create ${it.absolutePath}" }
/** 命中的候选标识(诊断用)。 */
@Volatile private var resolvedBy: String = "default(not-initialized)"
/** 全部候选的探测结果(诊断用)。 */
@Volatile private var probeResult: String = ""
val root: File get() = baseDir
val stateFile: File get() = File(root, "state.json")
val songsDir: File get() = File(root, "songs")
val importDir: File get() = File(root, "import")
val catalogFile: File get() = File(root, "catalog.json")
val recordsDir: File get() = File(root, "records")
val moviesDir: File get() = File(root, "movies")
val appsDir: File get() = File(root, "apps")
val backupDir: File get() = File(root, "backup")
val databaseDir: File get() = File(root, "database")
val databaseFile: File get() = File(databaseDir, "muse.db")
val videoDir: File get() = File(root, "video")
val cloudSongsDir: File get() = File(videoDir, "cloud-song")
/** 实际使用的根目录与候选探测结果(单行,便于远程日志检索)。 */
fun storageFacts(context: Context): String {
val env = runCatching { Environment.getExternalStorageDirectory()?.absolutePath }.getOrNull()
val envState = runCatching { Environment.getExternalStorageState() }.getOrNull()
val extFiles = runCatching { context.getExternalFilesDir(null)?.absolutePath }.getOrNull()
val files = runCatching { context.filesDir?.absolutePath }.getOrNull()
return buildString {
append("root=").append(baseDir.absolutePath)
append(" by=").append(resolvedBy)
append(" writable=").append(runCatching { baseDir.canWrite() }.getOrDefault(false))
append(" env=").append(env)
append(" envState=").append(envState)
append(" extFiles=").append(extFiles)
append(" files=").append(files)
append(" probe=[").append(probeResult).append(']')
}
}
/**
* 解析出一个**真正可写**的根目录。可重复调用(幂等)。
* 放在 `Application.attachBaseContext` 最前面,保证后续任何 AppPaths 访问都拿到正确值。
*/
fun init(context: Context) {
if (resolvedBy != "default(not-initialized)") return
val app = runCatching { context.applicationContext }.getOrDefault(context)
val candidates = LinkedHashMap<String, File>()
runCatching { Environment.getExternalStorageDirectory() }.getOrNull()?.let {
candidates["env(${it.absolutePath})"] = File(it, "MaidongKTV")
}
candidates["legacy($ROOT_PATH)"] = File(ROOT_PATH)
runCatching { app.getExternalFilesDir(null) ?: app.filesDir }.getOrNull()?.let {
candidates["private(${it.absolutePath})"] = File(it, "MaidongKTV")
}
runCatching { app.filesDir }.getOrNull()?.let {
candidates["internal(${it.absolutePath})"] = File(it, "MaidongKTV")
}
val envState = runCatching { Environment.getExternalStorageState() }.getOrNull() ?: "?"
val report = StringBuilder()
candidates.forEach { (tag, dir) ->
val ok = runCatching { (dir.isDirectory || dir.mkdirs()) && dir.canWrite() }
.getOrDefault(false)
if (report.isNotEmpty()) report.append("; ")
report.append(tag).append('=').append(if (ok) "ok" else "fail")
if (ok && resolvedBy == "default(not-initialized)") {
baseDir = dir
resolvedBy = tag
}
}
probeResult = report.toString()
Log.i(TAG, "storage root = ${baseDir.absolutePath} by=$resolvedBy envState=$envState")
Log.i(TAG, "storage probe = $probeResult")
}
/** 建目录。**永不抛异常**:存储不可用时只返回 false 并记录,由上层给出明确提示,而不是闪退。 */
fun ensureDirectories(): Boolean {
var allOk = true
listOf(root, songsDir, importDir, recordsDir, moviesDir, appsDir, backupDir,
databaseDir, videoDir, cloudSongsDir).forEach { dir ->
val ok = runCatching { dir.exists() || dir.mkdirs() }.getOrDefault(false)
if (!ok) {
allOk = false
Log.w(TAG, "cannot create directory: ${dir.absolutePath}")
}
}
return allOk
}
/** The original IJK binary creates this log folder internally; remove it after native startup. */
fun removeNativeLegacyLogDirectory() {
File("/storage/emulated/0/muse").deleteRecursively()
val env = runCatching { Environment.getExternalStorageDirectory()?.absolutePath }.getOrNull()
?: "/storage/emulated/0"
runCatching { File("$env/muse").deleteRecursively() }
}
private fun size(text: Long): String =
String.format(java.util.Locale.ROOT, "%.2fGB", text / 1024.0 / 1024.0 / 1024.0)
/**
* 真实写入探针:写 [sizeMB] 兆再删掉,验证「**现在真的能写**」。
*
* `usableSpace` 只反映**块空间**;inode 耗尽、配额、FUSE/ROM 限制都会返回 ENOSPC
* 但 still 报着几 GB 可用 —— 所以判断"是不是空间问题"必须靠真实写入,而不是看数字。
*/
fun probeWrite(dir: File, sizeMB: Int = 8): String = runCatching {
val probe = File(dir, ".write_probe.tmp")
try {
val buffer = ByteArray(1024 * 1024)
FileOutputStream(probe).use { out ->
repeat(sizeMB) { out.write(buffer) }
out.flush()
out.fd.sync()
}
"ok(${sizeMB}MB)"
} finally {
runCatching { probe.delete() }
}
}.getOrElse { "${it.javaClass.simpleName}: ${it.message}" }
private fun mountLines(): String = runCatching {
File("/proc/mounts").readLines()
.filter { line ->
line.contains("sdcard") || line.contains("/data ") ||
line.contains("/storage") || line.contains("/mnt")
}
.take(12)
.joinToString(" | ") { it.substringBefore(" ").trim() }
}.getOrNull() ?: "(unreadable)"
/** 一次性诊断串:空间、真实可写性、挂载点。失败时上报远程日志并显示在界面上。 */
fun diagnose(): String {
val dir = databaseDir
val free = runCatching { dir.usableSpace }.getOrDefault(0L)
val total = runCatching { dir.totalSpace }.getOrDefault(0L)
val envState = runCatching { Environment.getExternalStorageState() }.getOrNull()
return "root=${root.absolutePath} free=${size(free)} total=${size(total)} " +
"envState=$envState probe=${probeWrite(dir)} mounts=[${mountLines()}]"
}
}
@@ -77,7 +77,7 @@ object AppUpdateManager {
}
try {
require(connection.responseCode in 200..299) { "下载失败:HTTP ${connection.responseCode}" }
val total = connection.contentLengthLong
val total = connection.contentLengthCompat()
connection.inputStream.use { input ->
FileOutputStream(partial).use { output ->
val buffer = ByteArray(128 * 1024)
+26
View File
@@ -0,0 +1,26 @@
package com.local.ktv
import android.os.Build
import java.net.URLConnection
/**
* 低版本兼容工具。
*
* 背景:`minSdk 21`,但代码里用过若干 API 22 不存在的方法,在 Android 5.x
* (如极米 H1S / GMUI2.0 / Android 5.1.1)上会在运行期抛 `NoSuchMethodError`。
* 这里把这类调用收敛成带版本判断的兼容函数。
*/
/**
* 读取 HTTP 响应体长度。
*
* `URLConnection.getContentLengthLong()` 是 **API 24(N)** 才加入的;API 21~23 上直接调用
* 会抛 `NoSuchMethodError`。低版本回退到 `getContentLength()`(int,2GB 以内等价)。
*/
fun URLConnection.contentLengthCompat(): Long =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
contentLengthLong
} else {
@Suppress("DEPRECATION")
contentLength.toLong()
}
@@ -67,11 +67,12 @@ object DatabaseBootstrapper {
}.getOrNull()
/** 下载曲库:仅官方 Muse 同步源;成功后记录同步时间(重置 30 天时效)。 */
fun download(onProgress: (Int) -> Unit): Result<File> {
fun download(onProgress: (Int, String?) -> Unit): Result<File> {
return runCatching {
val file = MuseDbSync.download(onProgress)
// MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。
markSynced()
MiliLog.info("DB-SYNC download OK: ${file.absolutePath} size=${formatSize(file.length())}")
file
}.mapError(::readableError)
}
@@ -87,16 +88,26 @@ object DatabaseBootstrapper {
.any { "enospc" in it || "no space left" in it }
private fun readableError(error: Throwable): Throwable {
val target = MuseDatabase.defaultDbFile()
val free = target.parentFile?.usableSpace ?: 0L
// 完整异常链:老设备上「表面原因」常常误导(例如 ENOSPC 其实不是块空间不足)。
val chain = generateSequence(error) { it.cause }
.take(5)
.joinToString(" <- ") { "${it.javaClass.simpleName}: ${it.message}" }
val diagnose = runCatching { AppPaths.diagnose() }.getOrDefault("(diagnose failed)")
MiliLog.warn("DB-SYNC download failed | $chain | $diagnose")
Log.w(TAG, "Database download failed: $chain | $diagnose")
if (isNoSpaceError(error)) {
val target = MuseDatabase.defaultDbFile()
// ⚠️ 不能只看异常消息就断言"空间不足":inode 耗尽 / 配额 / ROM 限制同样报 ENOSPC,
// 而 usableSpace 可能还显示好几 GB(本机实测:报空间不足时可用 6.04GB)。
// 用真实写入探针区分"真的写不进去" vs "其实能写"。
val probe = runCatching { AppPaths.probeWrite(target.parentFile!!) }.getOrDefault("?")
return IllegalStateException(
"曲库分区空间不足(当前可用 ${formatSize(target.parentFile?.usableSpace ?: 0L)})," +
"已清理下载临时文件,请释放空间后重试",
"曲库写入被系统拒绝(探针 $probe,可用 ${formatSize(free)})\n原始错误:$chain",
error,
)
}
val message = error.message?.takeIf { it.isNotBlank() } ?: "曲库下载失败"
Log.w(TAG, "Database download failed: $message")
return IllegalStateException(message, error)
return IllegalStateException("曲库下载失败:$chain(可用 ${formatSize(free)})", error)
}
}
@@ -1,10 +1,28 @@
package com.local.ktv
import android.app.Application
import android.content.Context
import android.util.Log
import com.liulishuo.okdownload.OkDownload
class KtvApplication : Application() {
/**
* 存储根目录必须在**任何** AppPaths 访问之前解析好:
* 部分定制盒子(极米 H1S / Android 5.1.1)上 `/storage/emulated/0` 不可写,
* 历史实现直接崩溃。这里用 attachBaseContext 抢在所有业务代码之前完成探测。
*/
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
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) }
}
}
override fun onCreate() {
super.onCreate()
instanceRef = this
+157 -10
View File
@@ -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
@@ -42,11 +45,13 @@ class KtvJsBridge(context: Context) {
"https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js"
private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
private const val JS_REQUEST_TIMEOUT_MS = 180_000
/** WebView 初始化看门狗:超过这个时间还没就绪就判定本机 WebView 不可用。 */
private const val BRIDGE_INIT_TIMEOUT_MS = 20_000L
// 歌曲源(w.w345.my 与 e.ac19.cn 同后台)按 device 识别; 随机/变化的 device 会被风控。
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
private val DEVICE_MAC: String get() = DeviceMac.effective()
private const val DEVICE_SN = "2d7bb4a7d7f7"
internal const val DEVICE_SN = "2d7bb4a7d7f7"
/**
* 断网重连后重置歌曲源会话。
@@ -72,6 +77,17 @@ class KtvJsBridge(context: Context) {
@Volatile private var webView: WebView? = null
@Volatile private var ready = false
@Volatile private var destroyed = false
/**
* 已判定「本机 WebView 不可用」。
*
* 一旦置位,`SongApiClient` 就不再为取址白等 15 秒,直接走原生通道
* (极米 H1S 实测每次点歌都要等满 15 秒,日志里表现为每首歌前一条
* 「JS-BRIDGE 15s 未就绪」)。桥万一后来就绪(热更新重载等)会自动清除。
*/
@Volatile private var unavailable = false
private var unavailableReason: String = ""
private var loadedFromCache = false
private var assetRetryAttempted = false
@@ -93,8 +109,17 @@ class KtvJsBridge(context: Context) {
loadedFromCache = cachedScript != null
createWebView(cachedScript ?: readAssetScript(), callback)
updateCache(cached)
// 看门狗:onPageFinished 一直不回调(WebView 静默失效)时给出**明确原因**。
// 否则远程日志里只会看到反复的「15s 未就绪」,不知道卡在哪一步。
mainHandler.postDelayed({
if (!ready && !destroyed && !unavailable) {
markUnavailable("初始化超时:onPageFinished 未回调或 KtvApi 未导出")
callback(false)
}
}, BRIDGE_INIT_TIMEOUT_MS)
} catch (error: Throwable) {
Log.e(TAG, "WebView init failed", error)
markUnavailable("WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}")
callback(false)
}
}
@@ -108,11 +133,40 @@ 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") {
ready = true
// 桥后来就绪(例如热更新重载成功)→ 清除"不可用"标记,恢复 JS 主路径。
if (unavailable) {
unavailable = false
MiliLog.info("JS-BRIDGE 已恢复可用(${unavailableReason.ifEmpty { "?" }} 已解除)")
unavailableReason = ""
}
Log.i(TAG, "JS runtime ready (${if (loadedFromCache) "cache" else "asset"})")
MiliLog.info("JS-BRIDGE ready (${if (loadedFromCache) "cache" else "asset"})")
callback(true)
} else if (loadedFromCache && !assetRetryAttempted) {
Log.w(TAG, "Cached JS failed to load; falling back to packaged asset")
@@ -121,6 +175,9 @@ class KtvJsBridge(context: Context) {
createWebView(readAssetScript(), callback)
} else {
Log.e(TAG, "KtvApi was not exported by JS")
// 老电视盒子上 WebView 不可用/被裁剪时常走这里:桥永远不就绪,
// 取址只能回退 DB → 「无法生成下载地址」。必须能远程看到。
markUnavailable("KtvApi 未导出")
callback(false)
}
}
@@ -133,6 +190,32 @@ class KtvJsBridge(context: Context) {
/** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */
fun isReady(): Boolean = ready && !destroyed
/** 是否已判定本机 WebView 不可用(用于跳过必然失败的等待)。 */
fun isUnavailable(): Boolean = unavailable && !destroyed && !ready
/**
* 标记桥在本机不可用。**只在状态首次变化时上报一次**:
* 这是持久状态,若每次点歌都记一条会把远程日志刷满(实测每首歌一条)。
*/
fun markUnavailable(reason: String) {
if (unavailable) return
unavailable = true
unavailableReason = reason
MiliLog.error("JS-BRIDGE 判定本机 WebView 不可用($reason),后续点歌直接走原生取址通道")
}
/**
* 供**原生兜底取址解析器**([SongUrlResolver])复用的「干净请求」通道。
*
* 与 JS 的 `android.httpGet` 走的是**完全同一条**裸 Socket 路径(只发 Host + Accept,
* 不带 UA/Connection/Accept-Encoding),因此请求特征与风控表现一致;
* 且**不依赖 WebView 是否就绪** —— 正是 WebView 不可用的老设备所需要的。
*/
fun rawCleanGet(url: String, timeoutMs: Int = 8_000): String {
val headers = JSONObject().put("Accept", "*/*").toString()
return NativeBridge().request("GET", url, null, headers, timeoutMs)
}
fun isDestroyed(): Boolean = destroyed
/**
@@ -252,7 +335,7 @@ class KtvJsBridge(context: Context) {
runCatching {
file.isFile && file.length() in 1_001..500_000 &&
file.readText(Charsets.UTF_8).let {
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 8")
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 9")
}
}.getOrDefault(false)
@@ -271,14 +354,14 @@ class KtvJsBridge(context: Context) {
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IllegalStateException("HTTP ${connection.responseCode}")
}
val declaredLength = connection.contentLengthLong
val declaredLength = connection.contentLengthCompat()
require(declaredLength < 0 || declaredLength <= MAX_RESPONSE_BYTES) { "JS file is too large" }
val bytes = connection.inputStream.use { it.readBytes() }
require(bytes.size <= MAX_RESPONSE_BYTES) { "JS file is too large" }
val script = String(bytes, StandardCharsets.UTF_8)
require(
script.length > 1000 && script.contains("KtvApi") &&
script.contains("KTV_BRIDGE_API: 8"),
script.contains("KTV_BRIDGE_API: 9"),
) { "Invalid or incompatible JS file" }
tempFile.writeText(script, Charsets.UTF_8)
if (!tempFile.renameTo(cacheFile)) {
@@ -305,12 +388,49 @@ class KtvJsBridge(context: Context) {
private fun wrapHtml(script: String): String {
val escaped = script.replace("</script", "<\\/script", ignoreCase = true)
return "<!doctype html><html><head><meta charset=\"utf-8\"></head><body><script>$escaped</script></body></html>"
// 诊断 + console 桥(必须在主脚本之前执行):
// 1) 上报 WebView 内核版本与 ES2017(async/await) 支持情况 —— 老盒子(Android 5.x 的
// WebView 可能是 Chrome 39~4x)不支持 async/await 会让**整个脚本语法报错**,
// 表现为 `window.KtvApi` 未导出、桥永远不就绪;这条日志可直接定死该原因。
// (用 new Function 做特性探测,避免探测语句本身引起语法错误。)
// 2) window.onerror 捕获脚本解析/运行错误 —— 语法错误也会触发它,能拿到真实报错。
// 3) console.log 桥接:ktv_api.js 的 _log() 细节原本只进 WebView 控制台。
val consoleBridge = """
(function(){
try {
window.onerror = function(msg, src, line, col){
try { android.log('JS-ERROR ' + msg + ' @' + line + ':' + col); } catch(e) {}
};
} catch(e) {}
try {
var es = 'unknown';
try { new Function('return (async function(){})'); es = 'yes'; } catch(e) { es = 'no'; }
try {
android.log('WEBVIEW ua=' + navigator.userAgent +
' asyncFunction=' + es + ' promise=' + (typeof Promise));
} catch(e) {}
} catch(e) {}
try {
var orig = console.log;
console.log = function(){
try { android.log(Array.prototype.map.call(arguments, String).join(' ')); } catch(e) {}
if (orig) { try { orig.apply(console, arguments); } catch(e) {} }
};
} catch(e) {}
})();
""".trimIndent()
return "<!doctype html><html><head><meta charset=\"utf-8\"></head><body><script>$consoleBridge</script>" +
"<script>$escaped</script></body></html>"
}
inner class NativeBridge {
@JavascriptInterface
fun log(message: String) = Log.d(TAG, message)
fun log(message: String) {
Log.d(TAG, message)
// 把 JS 侧日志接进远程日志:歌曲源解析(token/do.php/多源排序)的失败原因
// 以前只走 console.log,现场完全看不到。加上前缀便于检索与过滤。
runCatching { MiliLog.info("JS ${message.take(180)}") }
}
@JavascriptInterface
fun onUrl(requestId: String, url: String) {
@@ -351,7 +471,8 @@ class KtvJsBridge(context: Context) {
}
}
private fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
/** 供 [rawCleanGet] 与 [NativeBridge.httpGet]/[httpPost] 共用的请求入口。 */
internal fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
// 歌曲源(如 w.w345.my)的 do.php 风控只放行"干净"请求(原版仅 Host+Accept;
// 带任何 UA/Connection/Accept-Encoding 头都会被拒)。裸 Socket 只发 Host+JS 头。
// 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection
@@ -425,10 +546,30 @@ class KtvJsBridge(context: Context) {
val isHttps = url.startsWith("https://")
val uri = URL(url).toURI()
val port = if (uri.port > 0) uri.port else if (isHttps) 443 else 80
val connectTimeout = timeoutMs.coerceIn(1_000, 30_000)
socket = if (isHttps) {
(SSLSocketFactory.getDefault().createSocket(uri.host, port) as SSLSocket).apply { startHandshake() }
// 用 TlsCompat 的工厂(系统信任 ∪ 内置根证书):裸 socket 走的是
// SSLSocketFactory.getDefault(),不受 HttpsURLConnection 的默认工厂影响,
// network_security_config 也管不到它 —— 老设备上会因缺 ISRG 新根握手失败。
//
// ⚠️ 必须用「先连接、再包装」的写法:`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 {
Socket(uri.host, port)
// ⚠️ 必须显式给连接超时:原写法 `Socket(host, port)` 的连接阶段**不受**
// soTimeout 约束,遇到路由黑洞会按系统 TCP 超时(约 2 分钟)干等,
// 多个源叠加会把 JS 的 3 分钟预算耗光,表现为「无法生成下载地址」。
Socket().apply {
connect(java.net.InetSocketAddress(uri.host, port), connectTimeout)
}
}
socket.soTimeout = timeoutMs.coerceIn(1_000, 30_000)
val path = if (uri.rawQuery != null) "${uri.rawPath}?${uri.rawQuery}" else uri.rawPath
@@ -469,6 +610,11 @@ class KtvJsBridge(context: Context) {
JSONObject().put("status", status).put("body", responseBody).toString()
} catch (error: Throwable) {
Log.w(TAG, "raw request failed: ${error.message}")
// 裸 Socket 失败原因以前只在 logcat:点歌失败时远程日志里什么都看不到。
MiliLog.warn(
"RAW-ERR $method ${runCatching { java.net.URI(url).host }.getOrNull() ?: url} " +
"${error.javaClass.simpleName}: ${error.message}",
)
JSONObject().put("status", 0).put("body", "").put("error", error.message.orEmpty()).toString()
} finally {
try { socket?.close() } catch (ignored: Exception) {}
@@ -508,7 +654,8 @@ class KtvJsBridge(context: Context) {
out.write(readExact(input, size.toLong()))
readAsciiLine(input) // 吃掉 chunk 尾部 CRLF
}
return out.toString(Charsets.UTF_8)
// ByteArrayOutputStream.toString(Charset) 是 API 33+;低版本用 String(bytes, charset)。
return String(out.toByteArray(), Charsets.UTF_8)
}
}
@@ -36,8 +36,15 @@ object KtvNetworkMonitor {
private var manager: ConnectivityManager? = null
private var callback: ConnectivityManager.NetworkCallback? = null
/** 开始监测;[onChange] 只在状态真正变化时回调(主线程)。 */
fun start(context: Context, onChange: (online: Boolean) -> Unit) {
/**
* 开始监测;[onChange] 在**状态变化**时回调(主线程)。
*
* ⚠️ 第二参数 [initial] 区分「启动时的初始状态」与「真正的断网→恢复」:
* 注册回调的当下系统就会回报一次初始状态,上层若把它当成"网络已恢复",
* 就会在启动阶段做一遍**重复且有害**的恢复动作(重建歌曲源会话、复位窗口守卫、
* 把状态栏写成"网络已恢复")。见 [MainActivity.onNetworkStateChanged]。
*/
fun start(context: Context, onChange: (online: Boolean, initial: Boolean) -> Unit) {
if (callback != null) return
val cm = context.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
@@ -76,7 +83,7 @@ object KtvNetworkMonitor {
manager = null
}
Log.i(TAG, "monitor started, online=$initial registered=$registered")
onChange(initial)
onChange(initial, true)
}
fun stop() {
@@ -88,17 +95,17 @@ object KtvNetworkMonitor {
Log.i(TAG, "monitor stopped")
}
private fun publish(value: Boolean, onChange: (Boolean) -> Unit) {
private fun publish(value: Boolean, onChange: (Boolean, Boolean) -> Unit) {
mainHandler.post {
if (value == online) return@post
online = value
Log.i(TAG, "network state changed: online=$value")
onChange(value)
onChange(value, false)
}
}
/** 主动复检一次(例如从后台回到前台),有变化才回调。 */
fun refresh(onChange: (online: Boolean) -> Unit) {
fun refresh(onChange: (online: Boolean, initial: Boolean) -> Unit) {
publish(currentOnline(manager), onChange)
}
+296 -72
View File
@@ -1,6 +1,7 @@
package com.local.ktv
import android.Manifest
import android.annotation.TargetApi
import android.app.AlertDialog
import android.content.DialogInterface
import android.content.DialogInterface.OnShowListener
@@ -106,8 +107,6 @@ import java.util.LinkedHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.function.Function
import java.util.function.ToIntFunction
import kotlin.Any
import kotlin.Array
import kotlin.Boolean
@@ -137,6 +136,29 @@ class MainActivity : AppCompatActivity() {
private val keyboardSearchRunnable = Runnable { performSearch() }
private val io: ExecutorService = Executors.newFixedThreadPool(4)
private val stateIo: ExecutorService = Executors.newSingleThreadExecutor()
/**
* 「切歌 → 公放」专用单线程执行器。
*
* ⚠️ **绝不能用 [io] 池**:它只有 4 个线程,"点歌即下即播"(`streamDownloadAndPlay`)这类
* 几十秒的长任务也在里面。极米 H1S 上并发点几首歌就能把池占满,切歌任务于是一直排队 ——
* 旧歌播完后彻底没声、按什么都没反应(用户报的"老设备卡死",新设备因下载快而看不出来)。
*/
private val randomPlayExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "ktv-random-play").apply { isDaemon = true }
}
/**
* 公放候选缓存(本机可播放歌曲):启动时后台预热一次,下载完成后增量更新。
*
* ⚠️ **切歌时不能现扫**:候选来自 `localSongInventory()` + `library.scanLocal()`,
* 代价是「每个文件在头尾各读约 4.3MB 做 TS 校验」+「遍历 `/storage/ 各挂载点`
* (老 ROM 上的失效挂载点会让 `File.listFiles()` 永久阻塞)」。新设备上很快,
* 极米 H1S 上足以让切歌看起来卡死。
*/
private val randomCandidates = java.util.concurrent.ConcurrentHashMap<String, Song>()
@Volatile private var randomCandidatesPrimed = false
@Volatile private var randomCandidatesPriming = false
private val library = SongLibrary()
private lateinit var stateDatabase: KtvStateDatabase
private lateinit var playbackEngine: KtvPlaybackEngine
@@ -188,12 +210,32 @@ class MainActivity : AppCompatActivity() {
}
/** 周期向 hbeat.ktvsky.com 上报设备身份,维持歌曲源对"合法设备"的判定。 */
private var heartbeatTicks = 0
private val ktvHeartbeatRunnable = object : Runnable {
override fun run() {
sendDeviceHeartbeat(boot = false)
// 每 6 小时留一条健康快照:日志此前只有"事件"(点歌/失败/升级),
// 没有"状态样本",现场排查空间/曲库/网络随时间的变化时缺参照。
heartbeatTicks++
if (heartbeatTicks % 6 == 0) logHealthSnapshot()
main.postDelayed(this, HEARTBEAT_INTERVAL_MS)
}
}
/** 健康快照:版本 / 在线 / 可用空间 / 曲库文件 —— 单行,便于按 HEALTH 检索。 */
private fun logHealthSnapshot() {
runCatching {
val db = AppPaths.databaseFile
val freeGb = AppPaths.databaseDir.usableSpace / 1024.0 / 1024.0 / 1024.0
MiliLog.notice(
"HEALTH ver=${BuildConfig.VERSION_NAME} online=${KtvNetworkMonitor.online}" +
" free=${String.format(Locale.ROOT, "%.2f", freeGb)}GB" +
" dbExist=${db.exists()} dbMB=${db.length() / 1024 / 1024}" +
" root=${AppPaths.root.absolutePath}",
)
}
}
private val visibleSongs: MutableList<Song> = ArrayList<Song>()
private val visibleSingers: MutableList<Array<String?>?> = ArrayList<Array<String?>?>()
private val visiblePlaylists: MutableList<Array<String?>> = ArrayList()
@@ -426,7 +468,11 @@ class MainActivity : AppCompatActivity() {
requestStorage()
return
}
AppPaths.ensureDirectories()
// 存储目录建不出来时**不再闪退**(极米 H1S / Android 5.1.1 的历史崩溃点),
// AppPaths.init 已在 Application.attachBaseContext 里挑好可写根目录。
if (!AppPaths.ensureDirectories()) {
Log.w(TAG, "存储目录创建失败: ${AppPaths.storageFacts(this)}")
}
store.load()
loadStateFromStore()
stateDatabase = KtvStateDatabase(applicationContext)
@@ -467,6 +513,10 @@ class MainActivity : AppCompatActivity() {
playbackEngine = KtvPlaybackEngine(applicationContext)
// 远程日志系统(GlogCenter): 启动即上报设备/网络信息。
MiliLog.init(applicationContext)
// ⚠️ STORAGE 探测含真实建目录/写盘与 /proc/mounts 读取,**不能放在主线程**,
// 而且必须在 refreshLogDeviceContext() 之后,否则这一行的
// serverip/clientip/user/traceid 全是 undefined(旧日志就是这样,无法按设备检索)。
// 现在统一在 reportDeviceBootInfo() 的后台线程里、awaitServerIp() 之后上报。
refreshLogDeviceContext()
reportDeviceBootInfo()
// 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403,
@@ -502,9 +552,18 @@ class MainActivity : AppCompatActivity() {
initializeDatabase()
startRemoteServer()
updateMobileQrOverlay()
// 一次性清理"同一段视频被当成多首不同歌曲"的注入广告副本。
// ⚠️ 只删可证明是广告的文件(多首歌内容完全相同);**绝不能清空整个歌曲目录** ——
// 1.4.10 那样做过,等于删掉公放曲库,导致「切歌」选不出下一首(新设备回归)。
io.execute { SongOkDownloadManager.purgeInjectedAdDuplicatesOnce(applicationContext) }
// 预热公放候选缓存(独立守护线程 + 内部超时),让之后的「切歌 → 公放」零 I/O。
// 延后一点避开曲库加载/启动高峰,但不影响正确性:预热完成前切歌会走安全兜底路径。
main.postDelayed({ primeRandomCandidates() }, 5_000L)
main.postDelayed(remoteNetworkMonitor, 5000)
// 网络状态监测: 断网提示 + 恢复后重建歌曲源会话/重试下载。
KtvNetworkMonitor.start(applicationContext) { online -> onNetworkStateChanged(online) }
KtvNetworkMonitor.start(applicationContext) { online, initial ->
onNetworkStateChanged(online, initial)
}
// 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。
sendDeviceHeartbeat(boot = true)
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
@@ -534,7 +593,7 @@ class MainActivity : AppCompatActivity() {
private fun registerMicInputWatcher() {
val manager = audioManager ?: return
if (Build.VERSION.SDK_INT >= 23) {
manager.registerAudioDeviceCallback(micDeviceCallback, null)
manager.registerAudioDeviceCallback(ensureMicDeviceCallback(), null)
} else {
runCatching {
val filter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
@@ -546,20 +605,37 @@ class MainActivity : AppCompatActivity() {
private fun unregisterMicInputWatcher() {
val manager = audioManager ?: return
if (Build.VERSION.SDK_INT >= 23) {
manager.unregisterAudioDeviceCallback(micDeviceCallback)
micDeviceCallback?.let { manager.unregisterAudioDeviceCallback(it) }
micDeviceCallback = null
} else {
runCatching { unregisterReceiver(micHotplugReceiver) }
}
}
private val micDeviceCallback = object : android.media.AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out android.media.AudioDeviceInfo>) {
handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) })
}
/**
* 输入设备变化回调(`android.media.AudioDeviceCallback`, API 23+)。
*
* ⚠️ **必须按需创建,不能写成字段初始化器**:字段初始化在 Activity 构造时执行,
* 早于 `onCreate`,而 `Build.VERSION.SDK_INT` 判断发生在 `onCreate` 之后。
* Android 5.x(API 22) 没有 `AudioDeviceCallback` 这个类 —— 对象在构造期被实例化会直接
* `NoClassDefFoundError` 闪退(极米 H1S / GMUI2.0 / Android 5.1.1 的闪退根因)。
*/
private var micDeviceCallback: android.media.AudioDeviceCallback? = null
override fun onAudioDevicesRemoved(removedDevices: Array<out android.media.AudioDeviceInfo>) {
handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
@TargetApi(23)
private fun ensureMicDeviceCallback(): android.media.AudioDeviceCallback {
micDeviceCallback?.let { return it }
val created = object : android.media.AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out android.media.AudioDeviceInfo>) {
handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) })
}
override fun onAudioDevicesRemoved(removedDevices: Array<out android.media.AudioDeviceInfo>) {
handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
}
}
micDeviceCallback = created
return created
}
private val micHotplugReceiver = object : BroadcastReceiver() {
@@ -568,7 +644,8 @@ class MainActivity : AppCompatActivity() {
}
}
/** 是否为可用的麦克风输入设备(内置/耳机/USB 等)。 */
/** 是否为可用的麦克风输入设备(内置/耳机/USB 等)。仅由 API 23+ 的输入设备回调调用。 */
@TargetApi(23)
private fun isMicInputDevice(device: android.media.AudioDeviceInfo): Boolean {
if (!device.isSource) return false
return when (device.type) {
@@ -592,6 +669,7 @@ class MainActivity : AppCompatActivity() {
private fun initializeDatabase() {
if (databaseBootstrapRunning) return
databaseBootstrapRunning = true
lastDatabaseProgressText = ""
showDatabaseLoading(true, "正在初始化曲库...", null)
io.execute {
var bootstrapError: Throwable? = null
@@ -621,7 +699,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()}),跳过服务器同步")
@@ -653,14 +739,28 @@ class MainActivity : AppCompatActivity() {
}
}
private fun showDatabaseDownloadProgress(value: Int) {
/**
* 最近一次真正刷到界面上的曲库进度文案。
*
* ⚠️ 曲库下载是 430MB,会回调约 1700 次;每次都在主线程做「改文本 + 改进度 + 置顶视图」
* 会把 UI 线程压死(表现为界面卡死、进度停滞,整机 CPU 被布局任务吃掉后下载本身也变慢)。
* 因此这里按**最终显示文案**去重:既能挡掉高频分块回调,又能让「正在解压曲库 xxxMB」
* 这类明细正常刷新。
*/
@Volatile private var lastDatabaseProgressText = ""
private fun showDatabaseDownloadProgress(value: Int, detail: String?) {
val percent = value.coerceIn(0, 100)
val phase = when {
value < 80 -> "正在下载并解压曲库"
value < 90 -> "正在完成解压"
value < 100 -> "正在校验数据库"
else -> "完成"
percent < 80 -> "正在下载曲库"
percent < 90 -> "正在解压曲库"
percent < 100 -> "正在校验曲库"
else -> "曲库就绪"
}
main.post { showDatabaseLoading(true, "$phase ${value}%", value) }
val text = detail ?: "$phase $percent%"
if (text == lastDatabaseProgressText) return
lastDatabaseProgressText = text
main.post { showDatabaseLoading(true, text, percent) }
}
private fun warmRemoteBrowseCache(count: Int) {
@@ -1688,7 +1788,7 @@ class MainActivity : AppCompatActivity() {
panel.addView(sectionTitle("本地导入"))
panel.addView(
label(
"请把歌曲放入 /sdcard/MaidongKTV/import、/sdcard/MaidongKTV/songs、U盘/KTV 或 U盘/songs,支持 mp4/mkv/avi/mp3/flac/wav。",
"请把歌曲放入 ${AppPaths.importDir.absolutePath}、${AppPaths.songsDir.absolutePath}、U盘/KTV 或 U盘/songs,支持 mp4/mkv/avi/mp3/flac/wav。",
20,
Color.LTGRAY
)
@@ -2978,7 +3078,7 @@ class MainActivity : AppCompatActivity() {
val adapter = tvAdapter()
val apks = localApks()
for (apk in apks) adapter.add(apk.getName() + " " + formatSize(apk.length()))
if (adapter.isEmpty()) adapter.add("暂无 APK,请放入 /sdcard/MaidongKTV/apps 或 U盘/apps")
if (adapter.isEmpty()) adapter.add("暂无 APK,请放入 ${AppPaths.appsDir.absolutePath} 或 U盘/apps")
list.setAdapter(adapter)
list.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long ->
if (pos >= 0 && pos < apks.size) installApk(apks.get(pos))
@@ -3616,13 +3716,24 @@ class MainActivity : AppCompatActivity() {
* 4. 拉起本地点歌服务并刷新二维码;
* 5. 自动重试断网期间失败的下载。
*/
private fun onNetworkStateChanged(online: Boolean) {
private fun onNetworkStateChanged(online: Boolean, initial: Boolean = false) {
fullScreenNetworkBadge?.visibility = if (online) View.GONE else View.VISIBLE
if (!online) {
status?.text = "网络未连接"
MiliLog.warn("NET-DOWN 设备网络已断开")
return
}
// ⚠️ initial=true 表示「启动时的初始状态」,**不是**「断网后恢复」。
// 启动流程(onCreate)已经做过 startRemoteServer()/updateMobileQrOverlay()/
// refreshLogDeviceContext()/DeviceMac 解析,这里再走一遍恢复逻辑只会:
// 1) 白白清掉刚建立的歌曲源会话(resetSourceSession);
// 2) 语义错误地 noteUrlOk(),可能掩盖真正的歌曲源窗口到期提示;
// 3) 把状态栏错写成"网络已恢复"(启动瞬间就显示,用户可见的错误文案)。
// 因此初始状态只更新断网角标,不做任何恢复动作。
if (initial) {
Log.i(TAG, "initial network state: online=true (skip recovery actions)")
return
}
MiliLog.notice("NET-UP 设备网络已恢复, 重建歌曲源会话")
if (!remoteServer.isRunning()) startRemoteServer()
refreshNetworkState("net-up", notify = true)
@@ -3679,6 +3790,9 @@ class MainActivity : AppCompatActivity() {
// 等主机 IP(公网出口 IP)就绪,避免启动这条日志的 serverip 为空。
MiliLog.awaitServerIp()
MiliLog.notice(sb.toString())
// 存储根目录探测结果(排查不同盒子存储差异用;单行便于按 STORAGE 检索)。
// 放在这里而不是启动主线程:既有完整上下文,又避免了主线程磁盘探测。
runCatching { MiliLog.notice("STORAGE ${AppPaths.storageFacts(this@MainActivity)}") }
} catch (ignored: Exception) {
}
}, "ktv-boot-log").start()
@@ -4179,7 +4293,7 @@ class MainActivity : AppCompatActivity() {
content!!.addView(left, LinearLayout.LayoutParams(dp(300), -1))
left.addView(sectionTitle("影片操作"))
left.addView(button("扫描影片目录", View.OnClickListener { v: View? -> showLocalMovies() }))
left.addView(label("目录:/sdcard/MaidongKTV/movies 或 U盘/movies", 18, Color.LTGRAY))
left.addView(label("目录:${AppPaths.moviesDir.absolutePath} 或 U盘/movies", 18, Color.LTGRAY))
val right = panel()
content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f))
@@ -4635,10 +4749,7 @@ class MainActivity : AppCompatActivity() {
song
)
}
Collections.sort<Song?>(
visibleSongs,
Comparator.comparing<Song?, String?>(Function { song: Song? -> song!!.title!!.lowercase() })
)
Collections.sort(visibleSongs, compareBy { it.title!!.lowercase() })
renderSongs()
}
@@ -4652,10 +4763,7 @@ class MainActivity : AppCompatActivity() {
))
) visibleSongs.add(song)
}
Collections.sort<Song?>(
visibleSongs,
Comparator.comparing<Song?, String?>(Function { song: Song? -> song!!.title!!.lowercase() })
)
Collections.sort(visibleSongs, compareBy { it.title!!.lowercase() })
renderSongs()
}
@@ -4710,10 +4818,7 @@ class MainActivity : AppCompatActivity() {
song
)
}
Collections.sort<Song?>(
visibleSongs,
Comparator.comparing<Song?, String?>(Function { song: Song? -> song!!.title!!.lowercase() })
)
Collections.sort(visibleSongs, compareBy { it.title!!.lowercase() })
renderSongs()
if (pageInfo != null) pageInfo!!.setText("本地 " + visibleSongs.size + " 首")
}
@@ -4947,6 +5052,20 @@ class MainActivity : AppCompatActivity() {
showDownloads()
}
/**
* 判断歌曲是否已有可播放的本地文件(**廉价**检查:只看存在与大小)。
*
* ⚠️ 播放路径上不要用 `Song.hasLocalFile()`:它内部是 `SongFileValidator.inspect`,
* 会在文件**头尾各读约 4.3MB** 做 TS 包校验 —— 极米 H1S 这类慢存储上一次调用就是好几秒,
* 足以让主线程 ANR(用户感知即"按了切歌卡死")。深度校验统一交给后台
* (`localSongInventory()` / `SongOkDownloadManager.isDownloaded()`)完成。
*/
private fun hasPlayableLocalFile(song: Song?): Boolean {
val path = song?.path?.takeIf { it.isNotEmpty() } ?: return false
val file = File(path)
return file.isFile && file.length() >= SongFileValidator.MIN_VALID_FILE_SIZE
}
private fun play(song: Song?) {
if (player == null) player = videoBackground
if (song == null) return
@@ -4954,7 +5073,7 @@ class MainActivity : AppCompatActivity() {
// 远程日志: 点歌使用的源(本地文件 / 在线源)。
MiliLog.info(
"PLAY id=${extractTid(song)} title=${song.title} src=" +
(if (song.hasLocalFile()) "local" else "online"),
(if (hasPlayableLocalFile(song)) "local" else "online"),
)
// 取消仍在后台扫描的“已点播完后随机本地歌曲”请求,避免抢占新点歌曲。
randomLocalRequestSerial++
@@ -4975,7 +5094,7 @@ class MainActivity : AppCompatActivity() {
updateBottomBar(song, true)
scheduleAutoFullscreen()
if (song.hasLocalFile()) {
if (hasPlayableLocalFile(song)) {
playLocalFile(song)
} else {
playbackPreparing = true
@@ -5103,6 +5222,7 @@ class MainActivity : AppCompatActivity() {
// 移到正式位置
tmpFile.renameTo(finalFile)
song.path = finalFile.getAbsolutePath()
randomCandidates[stableId(song)] = song
main.post(Runnable {
hideDownloadProgress()
@@ -5204,9 +5324,9 @@ class MainActivity : AppCompatActivity() {
// 远程日志: 播放网络/解码错误实时上报。
MiliLog.error(
"PLAY-ERR id=${song?.let(::extractTid)} title=${song?.title} what=$what extra=$extra" +
(if (song?.hasLocalFile() == true) " local" else " stream"),
(if (hasPlayableLocalFile(song)) " local" else " stream"),
)
if (songRetryCount < 2 && song != null && song.hasLocalFile()) {
if (songRetryCount < 2 && song != null && hasPlayableLocalFile(song)) {
songRetryCount++
val retry = songRetryCount
main.postDelayed(Runnable {
@@ -5223,7 +5343,7 @@ class MainActivity : AppCompatActivity() {
private fun switchToLocalPlayback(song: Song?) {
if (currentSong == null || !currentSong!!.equals(song)) return
if (!song!!.hasLocalFile()) return
if (song == null || !hasPlayableLocalFile(song)) return
var pos = 0
try {
pos = player!!.currentPosition
@@ -5366,6 +5486,8 @@ class MainActivity : AppCompatActivity() {
main.post(Runnable {
downloads.removeAll { stableId(it.song) == stableId(song) }
song.path = localPath
// 立刻进公放候选缓存:不必等下次预热扫描,新下载的歌马上能被随机到。
randomCandidates[stableId(song)] = song
refreshSongAdapter()
val shouldAddToQueue = pendingQueueSongIds.remove(stableId(song))
Log.i(
@@ -5487,6 +5609,11 @@ class MainActivity : AppCompatActivity() {
return
}
// 其它情况(没有公放曲目在播 / 用户主动停止):取消挂起的随机续播,进入空闲态。
// 一并停掉播放器并清空播放器状态:只要出现"音频在跑但 currentSong 已空",
// 那首歌播完时 onCompletion 就会因 currentSong 不匹配而直接 return,
// 之后按切歌/播放都没有反应(僵尸态)。
if (player != null) runCatching { player!!.stopPlayback() }
currentMediaPlayer = null
randomLocalRequestSerial++
playbackPreparing = false
playWhenPrepared = false
@@ -5511,11 +5638,23 @@ class MainActivity : AppCompatActivity() {
} else if (fromCompletion && autoNext && orderQueue.isEmpty()) {
// 点歌清单播放完毕 → 自动续播本地已下载歌曲
playRandomLocalSong()
} else if (!fromCompletion) {
// 点歌清单**最后一首**还在播时按「切歌」→ 直接进入公放(随机本地歌曲)。
// ⚠️ 旧逻辑在这里只把 currentSong 置空、**没有停播放器**:那首歌会继续播完,
// 而 onCompletion 的第一行是 `currentSong?.equals(song) != true → return`,
// 于是再也不会推进 —— 形成"音频已结束 / currentSong 为空 / playWhenPrepared=false"
// 的僵尸态,之后按「切歌」「播放」都毫无反应(用户报的卡死)。
// 公放清单与点歌清单互不影响:随机歌曲不入 orderQueue,点歌时又会立即取消随机请求。
MiliLog.info("点歌清单已空,切歌转入公放(随机本地歌曲)")
playRandomLocalSong(skipped = true)
} else {
// 显式切歌但队列已空:同样取消挂起的随机续播,避免用户停歌后又被随机歌打断。
// 手动切歌模式(autoNext=false)下自然播完:保持原逻辑,停在空闲态等待点歌。
// 这里把播放器一并停掉并清空状态,避免出现上面那种"音频在跑、状态已清"的僵尸态。
if (player != null) runCatching { player!!.stopPlayback() }
randomLocalRequestSerial++
playbackPreparing = false
playWhenPrepared = false
currentMediaPlayer = null
releaseVocalPlayer()
currentSong = null
clearLyrics()
@@ -5543,6 +5682,9 @@ class MainActivity : AppCompatActivity() {
val requestSerial = ++randomLocalRequestSerial
playbackPreparing = true
playWhenPrepared = true
// 这里**不立刻停播放器**:本地曲库扫描(含文件校验)要花一点时间,
// 让当前这首歌继续放到下一首真正开始时再停,避免"切歌后一段静音"。
// 僵尸态的防范放在"确实没歌可播"的分支里(见下面 selected == null 的处理)。
releaseVocalPlayer()
currentSong = null
clearLyrics()
@@ -5554,17 +5696,21 @@ class MainActivity : AppCompatActivity() {
}
persistRuntimeState()
io.execute {
val localSongs = localSongInventory()
library.scanLocal().forEach { song ->
val file = song.path?.let(::File) ?: return@forEach
if (SongFileValidator.inspect(
file,
SongFileValidator.requiresTransportStream(file),
).valid
) {
localSongs.putIfAbsent(stableId(song), song)
}
// ⚠️ 两条硬约束:
// 1) 必须用 randomPlayExecutor(专用线程),否则会被几十秒的下载任务挤在后面排队;
// 2) 必须优先读预热缓存,不能在切歌时现扫目录(老盒子上"逐文件校验 + 遍历 /storage/ 各挂载点"
// 会让切歌看起来卡死)。
randomPlayExecutor.execute {
val localSongs = LinkedHashMap<String, Song>()
randomCandidates.forEach { (id, song) -> localSongs[id] = song }
if (localSongs.isEmpty()) {
// 缓存还没预热好(例如刚开机就切歌):做一次**安全的**库存查询兜底。
// 刻意不调用 library.scanLocal() —— 它会遍历 /storage/ 各挂载点,老 ROM 的失效挂载点
// 可能让 File.listFiles() 永久阻塞;外部目录交给带超时的预热流程去补。
runCatching {
localSongInventory().forEach { (id, song) -> localSongs[id] = song }
}.onFailure { Log.w(TAG, "random candidates fallback failed", it) }
primeRandomCandidates()
}
val allLocalSongs = localSongs.values.toList()
main.post {
@@ -5583,6 +5729,12 @@ class MainActivity : AppCompatActivity() {
)
play(selected)
} else {
// ⚠️ 本地确实没有可播歌曲:必须**停掉播放器**再进入空闲态。
// 否则会出现"歌曲还在放、currentSong 却已置空"的僵尸态 —— 那首歌播完时
// onCompletion 因 `currentSong?.equals(song) != true` 直接 return,
// 之后按切歌/播放都没反应(本轮用户报的卡死正是这一类状态)。
if (player != null) runCatching { player!!.stopPlayback() }
currentMediaPlayer = null
playbackPreparing = false
playWhenPrepared = false
updateBottomBar(null, false)
@@ -5971,10 +6123,7 @@ class MainActivity : AppCompatActivity() {
BufferedReader(InputStreamReader(FileInputStream(file), "UTF-8")).use { reader ->
var line: String?
while ((reader.readLine().also { line = it }) != null) parseLyricLine(line)
Collections.sort<LyricLine?>(
lyricLines, Comparator.comparingInt<LyricLine?>(
ToIntFunction { item: LyricLine? -> item!!.timeMs })
)
Collections.sort(lyricLines, compareBy { it!!.timeMs })
updateLyricView()
}
} catch (e: Exception) {
@@ -8415,7 +8564,7 @@ class MainActivity : AppCompatActivity() {
.plus(library.allSongs().asSequence())
.forEach { song ->
if (song.hasLocalFile() || isDownloaded(song)) {
localSongs.putIfAbsent(stableId(song), song)
localSongs.getOrPut(stableId(song)) { song }
}
}
val filtered = localSongs.values.filter {
@@ -8676,6 +8825,66 @@ class MainActivity : AppCompatActivity() {
private fun countDownloadedFiles(): Int = localSongInventory().size
/**
* 预热公放候选缓存(启动时调用一次;下载完成后由回调增量补充)。
*
* 用**独立守护线程**而不是 [io] 池:里面的 `library.scanLocal()` 在老盒子上可能永久阻塞,
* 绝不能占住 io 线程;[scanWithTimeout] 保证即使它卡住,也只损失"导入目录/外置存储"那部分候选,
* 已下载曲库照样可用。
*/
private fun primeRandomCandidates() {
if (randomCandidatesPriming || randomCandidatesPrimed) return
randomCandidatesPriming = true
Thread({
val startedAt = System.currentTimeMillis()
runCatching {
// 1) 已下载曲库:只读我们自己的目录 + 曲库 DB,安全。
localSongInventory().forEach { (id, song) -> randomCandidates.putIfAbsent(id, song) }
// 2) 导入目录 / 外置存储:遍历 /storage/ 各挂载点 有阻塞风险,必须带超时。
val scanned = scanWithTimeout(RANDOM_SCAN_TIMEOUT_MS) { library.scanLocal() }
if (scanned == null) {
MiliLog.warn("RANDOM-CACHE 目录扫描超时(${RANDOM_SCAN_TIMEOUT_MS}ms),仅使用已下载曲库")
} else {
scanned.forEach { song ->
val file = song.path?.let(::File) ?: return@forEach
if (SongFileValidator.inspect(
file,
SongFileValidator.requiresTransportStream(file),
).valid
) {
randomCandidates.putIfAbsent(stableId(song), song)
}
}
}
}.onFailure { Log.w(TAG, "prime random candidates failed", it) }
randomCandidatesPrimed = true
randomCandidatesPriming = false
MiliLog.info(
"RANDOM-CACHE 预热完成 size=${randomCandidates.size} " +
"用时=${System.currentTimeMillis() - startedAt}ms",
)
}, "ktv-random-prime").apply { isDaemon = true }.start()
}
/**
* 在独立守护线程里执行"可能永久阻塞"的文件系统扫描,超时返回 null。
*
* 不能放进任何线程池:老盒子(极米 H1S,非标准 ROM)上 `/storage/ 各挂载点` 里的失效挂载点
* 会让 `File.listFiles()` 无限期阻塞,占住池线程会连带拖死后续所有任务。
*/
private fun <T> scanWithTimeout(timeoutMs: Long, block: () -> T): T? {
val holder = java.util.concurrent.atomic.AtomicReference<T>()
val done = java.util.concurrent.CountDownLatch(1)
Thread({
runCatching { holder.set(block()) }
done.countDown()
}, "ktv-scan").apply { isDaemon = true }.start()
val finished = runCatching {
done.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)
}.getOrDefault(false)
return if (finished) holder.get() else null
}
private fun localSongInventory(): LinkedHashMap<String, Song> {
val result = LinkedHashMap<String, Song>()
library.muse.localPathSongs().forEach { song ->
@@ -8686,7 +8895,7 @@ class MainActivity : AppCompatActivity() {
).valid
) {
song.path = resolved.absolutePath
result.putIfAbsent(stableId(song), song)
result.getOrPut(stableId(song)) { song }
}
}
@@ -8711,13 +8920,13 @@ class MainActivity : AppCompatActivity() {
val databaseSongs = library.muse.songsByFilenames(filesByName.keys)
databaseSongs.forEach { song ->
filesByName[song.filename]?.let { song.path = it.absolutePath }
result.putIfAbsent(stableId(song), song)
result.getOrPut(stableId(song)) { song }
}
val matchedNames = databaseSongs.mapNotNullTo(HashSet()) { it.filename }
validFiles.filter { it.name !in matchedNames }.forEach { file ->
val song = Song.local(file.absolutePath, file.name)
song.filename = file.name
result.putIfAbsent(stableId(song), song)
result.getOrPut(stableId(song)) { song }
}
return result
}
@@ -8864,18 +9073,31 @@ class MainActivity : AppCompatActivity() {
root.addView(overlay, FrameLayout.LayoutParams(-1, -1))
databaseLoadingOverlay = overlay
}
databaseLoadingOverlay?.visibility = View.VISIBLE
databaseLoadingText?.text = message
databaseLoadingProgress?.apply {
visibility = if (progressValue == null) View.INVISIBLE else View.VISIBLE
progress = progressValue ?: 0
// ⚠️ 这里是**每次进度刷新都会走**的路径(曲库下载 430MB)。必须做到「只在真正变化时
// 才改 View、且不做重排」:早先的实现每次都 setText + 改 progress + setOnClickListener(null)
// + bringToFront()(整棵视图树 requestLayout),把主线程压死 → 界面卡死、下载也变慢。
val overlay = databaseLoadingOverlay ?: return
if (overlay.visibility != View.VISIBLE) {
overlay.visibility = View.VISIBLE
overlay.bringToFront()
}
databaseRetryButton?.apply {
visibility = View.GONE
isEnabled = true
setOnClickListener(null)
val textView = databaseLoadingText
if (textView != null && textView.text?.toString() != message) {
textView.text = message
}
val bar = databaseLoadingProgress
if (bar != null) {
val wanted = if (progressValue == null) View.INVISIBLE else View.VISIBLE
if (bar.visibility != wanted) bar.visibility = wanted
val percent = progressValue ?: 0
if (bar.progress != percent) bar.progress = percent
}
val retry = databaseRetryButton
if (retry != null && retry.visibility == View.VISIBLE) {
retry.visibility = View.GONE
retry.isEnabled = true
retry.setOnClickListener(null)
}
databaseLoadingOverlay?.bringToFront()
}
private fun showDatabaseLoadingFailure(message: String) {
@@ -9651,7 +9873,7 @@ class MainActivity : AppCompatActivity() {
return SongRowState(
current = currentSong?.equals(song) == true,
queued = orderQueue!!.contains(song),
local = song.hasLocalFile(),
local = hasPlayableLocalFile(song),
downloading = isDownloading(song),
failed = taskState.startsWith("失败"),
paused = taskState.contains("暂停") || taskState.contains("中断"),
@@ -9885,7 +10107,7 @@ class MainActivity : AppCompatActivity() {
}
private fun isSongReadyForQueue(song: Song): Boolean =
SongOkDownloadManager.isDownloaded(song) || song.hasLocalFile()
hasPlayableLocalFile(song) || SongOkDownloadManager.isDownloaded(song)
private fun addReadySongToQueue(song: Song) {
if (orderQueue!!.contains(song)) return
@@ -10483,6 +10705,8 @@ class MainActivity : AppCompatActivity() {
private const val LEGACY_PACKAGE = "com.origjoy.local.ktv"
private const val LEGACY_KTV_ID = "10006789" // 门店/包厢 id
private const val HEARTBEAT_INTERVAL_MS = 3600_000L
/** 公放候选预热时,外部目录扫描(可能遍历 /storage/ 各挂载点)的超时上限。 */
private const val RANDOM_SCAN_TIMEOUT_MS = 4_000L
// ===== 主题色(对标原版麦动 配色) =====
/** 主背景:极深蓝黑(原版 #080C12 系) */
@@ -584,11 +584,14 @@ class MuseDatabase @JvmOverloads constructor(initialSourceFile: File? = null) {
companion object {
private const val TAG = "MuseDatabase"
private const val DEFAULT_CDN_PATH = "https://pub.mcdn.cherryonline.cn/"
private const val DEFAULT_DB_PATH = "${AppPaths.ROOT_PATH}/database/muse.db"
const val PAGE_SIZE = 10
/** 搜索结果条数统计上限:达到该值即视为“足够多”,终止 COUNT 扫描以加速检索。 */
const val SEARCH_COUNT_CAP = 2000
const val VIDEO_ROOT = AppPaths.VIDEO_PATH
/**
* 视频根目录。**必须是运行时取值**:存储根由 [AppPaths.init] 在启动时解析,
* 不能再像以前那样用 `const val` 把 `/storage/emulated/0/...` 编译期内联进去。
*/
val VIDEO_ROOT: String get() = AppPaths.videoDir.absolutePath
const val CLOUD_SONG_DIR = "cloud-song"
const val LOCAL_SONG_DIR = "local"
@@ -623,10 +626,10 @@ class MuseDatabase @JvmOverloads constructor(initialSourceFile: File? = null) {
fun songDirectories(): List<File> = listOf(AppPaths.cloudSongsDir)
@JvmStatic
fun defaultDbFile(): File = File(DEFAULT_DB_PATH)
fun defaultDbFile(): File = AppPaths.databaseFile
private fun findDatabaseFile(): File? {
return File(DEFAULT_DB_PATH).takeIf { it.exists() && it.canRead() }
return AppPaths.databaseFile.takeIf { it.exists() && it.canRead() }
}
private fun pageArgs(limit: Int, offset: Int) = arrayOf(limit.toString(), offset.toString())
+59 -12
View File
@@ -50,8 +50,12 @@ object MuseDbSync {
file.version
}.getOrNull()
/** 下载并安装曲库。失败抛异常,由调用方决定是否回退到其它源。 */
fun download(onProgress: (Int) -> Unit): File {
/**
* 下载并安装曲库。
* @param onProgress (百分比, 明细文案);明细非空时优先显示(如「正在解压曲库 512MB」),
* 因为解压/校验阶段很慢,只给百分比会让界面长时间不动、看着像卡死。
*/
fun download(onProgress: (Int, String?) -> Unit): File {
val cookie = login() ?: error("登录曲库服务失败")
val file = sync(cookie) ?: error("曲库同步失败或无可用文件")
val version = file.version
@@ -59,23 +63,32 @@ object MuseDbSync {
val gzFile = File(target.parentFile, "${target.name}.gz.download")
val tmpDb = File(target.parentFile, "${target.name}.download")
try {
downloadFile(file.url, gzFile) { pct -> onProgress((pct * 80 / 100).coerceIn(0, 80)) }
onProgress(80)
downloadFile(file.url, gzFile) { pct -> onProgress((pct * 80 / 100).coerceIn(0, 80), null) }
onProgress(80, "正在校验曲库文件")
if (file.md5.isNotEmpty()) {
check(md5(gzFile).equals(file.md5, ignoreCase = true)) { "曲库文件校验失败" }
}
onProgress(84)
onProgress(84, "正在解压曲库 0MB")
GZIPInputStream(gzFile.inputStream().buffered(256 * 1024)).use { gz ->
BufferedOutputStream(FileOutputStream(tmpDb)).use { out ->
val buffer = ByteArray(256 * 1024)
var written = 0L
var lastStep = -1
while (true) {
val count = gz.read(buffer)
if (count < 0) break
out.write(buffer, 0, count)
written += count
// 解压约 1GB、在该设备上要几分钟:每 64MB 报一次,界面才不会像是卡死。
val step = (written / (64L * 1024 * 1024)).toInt()
if (step != lastStep) {
lastStep = step
onProgress(84, "正在解压曲库 ${written / 1048576}MB")
}
}
}
}
onProgress(96)
onProgress(96, "正在安装曲库")
val backup = File(target.parentFile, "${target.name}.backup")
backup.delete()
if (target.exists()) check(target.renameTo(backup)) { "无法备份旧曲库" }
@@ -85,10 +98,14 @@ object MuseDbSync {
}
backup.delete()
saveVersion(version)
onProgress(100)
onProgress(100, "曲库就绪")
return target
} finally {
gzFile.delete()
// 失败时两个临时文件都要清掉:残留的 .gz.download(430MB) + .download(965MB)
// 白占约 1.4GB,反复重试时正是"空间不足"的常见来源。
// 成功路径上 tmpDb 已 rename 成正式库,这里的 delete 是空操作。
runCatching { gzFile.delete() }
runCatching { tmpDb.delete() }
}
}
@@ -143,13 +160,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 +193,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()
@@ -194,7 +220,9 @@ object MuseDbSync {
}
val code = conn.responseCode
check(code in 200..299) { "HTTP $code" }
val total = conn.contentLengthLong
val total = conn.contentLengthCompat()
val startedAt = System.currentTimeMillis()
var lastPercent = -1
conn.inputStream.buffered(256 * 1024).use { input ->
dest.outputStream().buffered(256 * 1024).use { out ->
val buffer = ByteArray(256 * 1024)
@@ -204,7 +232,26 @@ object MuseDbSync {
if (count < 0) break
out.write(buffer, 0, count)
read += count
if (total > 0) onProgress(((read * 100) / total).toInt().coerceIn(0, 99))
if (total > 0) {
val percent = ((read * 100) / total).toInt().coerceIn(0, 99)
// ⚠️ 必须按百分比去重:430MB 会读约 1700 个 256KB 分块,
// 每块都回调一次会把主线程 Looper 队列压满(界面卡死、整机 CPU 被
// 布局任务吃掉,下载本身也跟着变慢)。
if (percent != lastPercent) {
lastPercent = percent
onProgress(percent)
if (percent % 10 == 0) {
val seconds = ((System.currentTimeMillis() - startedAt) / 1000.0)
.coerceAtLeast(0.001)
val mbps = read / 1048576.0 / seconds
MiliLog.info(
"MUSE-DB progress $percent% " +
"${read / 1048576}MB/${total / 1048576}MB " +
"${String.format(Locale.ROOT, "%.2f", mbps)}MB/s",
)
}
}
}
}
}
}
@@ -39,7 +39,9 @@ object SingerAvatarLoader {
return
}
val waiters = waiting.computeIfAbsent(key) { CopyOnWriteArrayList() }
// ConcurrentHashMap.computeIfAbsent 是 API 24+;putIfAbsent 自 API 1 起可用,语义等价。
val created = CopyOnWriteArrayList<WeakReference<ImageView>>()
val waiters = waiting.putIfAbsent(key, created) ?: created
waiters += WeakReference(view)
if (waiters.size > 1) return
val cacheDir = File(view.context.applicationContext.cacheDir, "singer_avatars").apply { mkdirs() }
@@ -70,7 +72,7 @@ object SingerAvatarLoader {
connection.instanceFollowRedirects = true
connection.setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
require(connection.responseCode in 200..299)
require(connection.contentLengthLong <= 0 || connection.contentLengthLong <= MAX_DOWNLOAD_BYTES)
require(connection.contentLengthCompat() <= 0 || connection.contentLengthCompat() <= MAX_DOWNLOAD_BYTES)
var total = 0L
connection.inputStream.buffered().use { input ->
FileOutputStream(temporary).buffered().use { output ->
@@ -24,8 +24,12 @@ object SongApiClient {
fun init(context: Context) {
if (bridge != null) return
bridge = KtvJsBridge(context)
// 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致),
// 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。
SongUrlResolver.install { url -> bridge?.rawCleanGet(url) }
bridge?.init { ok ->
ready = ok
// 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。
Log.i(TAG, "JS Bridge init: $ok")
}
}
@@ -46,11 +50,19 @@ 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 DB fallback")
Log.w(TAG, "Synchronous request on main thread; using DB fallback only")
return fetchFromDb(musicno)
}
val bridge = bridge ?: return fetchFromDb(musicno)
val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
// 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。
// (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。)
if (bridge.isUnavailable()) {
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
}
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
@@ -62,8 +74,12 @@ object SongApiClient {
}
}
if (!bridge.isReady()) {
Log.w(TAG, "Bridge not ready after wait, DB fallback")
return fetchFromDb(musicno)
// ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器,
// 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。
Log.w(TAG, "Bridge not ready after wait, native resolver")
bridge.markUnavailable("15s 未就绪")
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
}
val result = AtomicReference<String?>()
val latch = CountDownLatch(1)
@@ -74,7 +90,9 @@ object SongApiClient {
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
val url = result.get() ?: fetchFromDb(musicno)
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
// 避免 JS 侧偶发失败直接把第一首歌判死。
val url = result.get() ?: resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
if (url != null) {
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
Eac19WindowGuard.noteUrlOk()
@@ -82,15 +100,26 @@ object SongApiClient {
return url
}
/**
* 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。
* 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。
*/
private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): String? =
runCatching { SongUrlResolver.resolve(musicno, resolution, h265) }
.onFailure { Log.w(TAG, "native resolver failed", it) }
.getOrNull()
/** DB cloud_url fallback — 必须通过 demo 检测才能使用 */
private fun fetchFromDb(musicno: String): String? {
try {
private fun fetchFromDb(musicno: String): String? { try {
val db = MuseDatabase()
if (db.open()) {
val url = db.getCloudUrl(musicno)
db.close()
// DB 中的 cloud_url 可能也是 demo 地址, 做 URL 基本校验
if (url != null && !isLikelyDemoUrl(url)) return url
// DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验
// (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。
if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) {
return url
}
}
} catch (_: Exception) {}
return null
@@ -123,7 +152,8 @@ object SongApiClient {
bridge?.resetApiSession()
}
/** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */
/** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */
@JvmStatic
fun lastSongSourceHost(): String = bridge?.lastSongSourceHost.orEmpty()
fun lastSongSourceHost(): String =
bridge?.lastSongSourceHost?.takeIf { it.isNotEmpty() } ?: SongUrlResolver.lastHost
}
@@ -23,6 +23,9 @@ object SongOkDownloadManager {
private const val MAX_RETRY_COUNT = 3
private const val STALL_TIMEOUT_MS = 25_000L
/** 曲库视频目录下会被当作"已下载歌曲"的文件后缀(用于一次性清理历史广告缓存)。 */
private val MEDIA_EXTENSIONS = setOf("ts", "ls", "mp4", "mkv")
interface DownloadCallback {
fun onDownloadStart(song: Song)
fun onDownloadProgress(song: Song, progress: Int)
@@ -32,12 +35,16 @@ object SongOkDownloadManager {
}
private val tasks = ConcurrentHashMap<String, OkDownloadTask>()
private val pending = ConcurrentHashMap.newKeySet<String>()
// ConcurrentHashMap.newKeySet() 是 API 24+(低版本 NoSuchMethodError);
// Collections.newSetFromMap 自 API 9 起可用,且返回普通 Set,add/remove/contains 均成立于 API 1。
private val pending: MutableSet<String> =
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val callbacks = ConcurrentHashMap<String, DownloadCallback>()
private val progress = ConcurrentHashMap<String, Int>()
private val totalLengths = ConcurrentHashMap<String, Long>()
private val forcedRetryReasons = ConcurrentHashMap<String, String>()
private val manuallyCanceled = ConcurrentHashMap.newKeySet<String>()
private val manuallyCanceled: MutableSet<String> =
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val io = Executors.newFixedThreadPool(4)
private val watchdog = Executors.newSingleThreadScheduledExecutor()
@@ -69,6 +76,76 @@ object SongOkDownloadManager {
return downloaded
}
/**
* 一次性清理"同一段视频被当成多首不同歌曲"的注入广告。
*
* 背景(2026-09-26 事故):1.4.9 及以前,JS 取址返回的地址在 `buildDownloadUrl` 的成功分支里
* **完全没有过广告校验**,广告视频(如 `gz.ac16.vip/ad_files/my_ad_video.ts`)会被当成歌曲
* 下载并落盘;它又能通过 [SongFileValidator] 的内容校验(≥6MB / ≥60s / TS 包完整),
* 而且 [isDownloaded] 会直接复用该文件、连取址都不会再发生 —— 广告会一直播下去。
*
* ⚠️ **不能像 1.4.10 那样直接清空整个歌曲目录**:`MuseDatabase.songDirectories()` 只有
* `cloudSongsDir` 一个目录,那等于把**公放曲库整个删掉** —— 库里没歌,
* `playRandomLocalSong()` 选不出候选、`currentSong` 保持 null,
* 于是「切歌」落进空闲态分支、按了没反应(新设备上的回归)。
*
* 这里改为**只删真正能证明是注入广告的文件**:广告对每首歌都下发**同一个视频文件**,
* 所以它的特征是「多首不同歌曲的本地文件**内容完全相同**」。按「文件大小 + 头部指纹」
* 分组,只清理 ≥2 个文件完全相同的组 —— 正常歌曲各有各的内容,绝不会被误删。
*
* @return 删除的文件数(已清理过则返回 0)
*/
@JvmStatic
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 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 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)
@@ -169,33 +246,71 @@ object SongOkDownloadManager {
}
private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String {
// ⚠️ 只有「从文件名推出」的点歌编号才可信,才能参与 `.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.isAdVideo(cached)) return cached
// 缓存的下载地址是广告视频,清掉并重新解析(广告会被桥接层屏蔽/替换)。
Log.w(TAG, "丢弃广告缓存地址: ${song.title}")
if (AdVideoFilter.isTrustedSongUrl(cached, musicNoFromFile)) return cached
// 缓存的地址不可信(广告 / 文件名与点歌编号不符):除了清地址,还要**删掉已落盘的
// 同名文件** —— 广告视频能通过内容校验(≥6MB / ≥60s / TS 包完整),
// 一旦被缓存成歌曲,isDownloaded() 会直接复用、连取址都不会再发生。
Log.w(TAG, "丢弃不可信缓存地址: ${song.title} -> $cached")
MiliLog.warn("SONG-AD 丢弃不可信缓存地址 编号=$musicNo url=$cached")
song.downloadUrl = null
purgeCachedSongFile(song, "untrusted-cached-url")
}
}
val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id
return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url ->
// 请求源 = 本次点歌实际命中的源(JS 上报); 取不到时留空。
val srcHost = SongApiClient.lastSongSourceHost()
if (url.isNotEmpty()) {
// ⚠️ 广告漏放的**最终兜底防线**:不管地址来自 JS 取址、原生兜底还是 DB 回退,
// 都必须过 AdVideoFilter.isTrustedSongUrl(2026-09-26 事故:JS 路径返回
// gz.ac16.vip 的广告视频,因为这里的成功分支完全没做广告校验而被当成歌曲下载播放)。
if (url.isNotEmpty() && AdVideoFilter.isTrustedSongUrl(url, musicNoFromFile)) {
song.downloadUrl = url
val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown"
MiliLog.info(
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=成功 urlHost=$cdnHost",
)
} else if (url.isNotEmpty()) {
Log.w(TAG, "Untrusted URL rejected for ${song.title}: $url")
MiliLog.error(
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 " +
"详情=地址不可信(广告或文件名不符) url=$url",
)
purgeCachedSongFile(song, "untrusted-resolved-url")
""
} else {
Log.w(TAG, "Unable to get download URL: ${song.title}")
MiliLog.error(
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 详情=无法生成下载地址",
)
""
}
}
}
/** 删除某首歌已落盘的文件(发现地址不可信/命中广告时调用,让下次点歌重新取址下载)。 */
private fun purgeCachedSongFile(song: Song, reason: String) {
runCatching {
val file = getLocalFile(song)
if (file.exists()) {
val sizeMb = file.length() / 1024 / 1024
SongFileValidator.forget(file)
val deleted = file.delete()
MiliLog.warn(
"SONG-AD 清除可疑歌曲缓存 编号=${song.id} 原因=$reason " +
"大小=${sizeMb}MB 已删除=$deleted",
)
}
song.path = null
}.onFailure { Log.w(TAG, "purge cached file failed: ${it.message}") }
}
private fun startDownloadTask(song: Song, url: String, callback: DownloadCallback?, attempt: Int) {
val key = songKey(song)
val target = getLocalFile(song)
@@ -0,0 +1,225 @@
package com.local.ktv
import android.util.Log
import org.json.JSONObject
import java.security.MessageDigest
import java.util.Collections
/**
* 歌曲取址的**原生兜底实现**。
*
* ## 为什么需要它
* 取址原本完全依赖隐藏 WebView 里的 `ktv_api.js`。但在一部分老电视盒子/投影上
* (实测:极米 H1S / GMUI2.0 / Android 5.1.1 / API 22)**WebView 不可用**:
* `KtvJsBridge` 永远 `isReady()==false`,`SongApiClient` 等 15 秒后只能回退到数据库里的
* `cloud_url`,取不到就报「无法生成下载地址」。特征是**远程日志里连一条 `JS ...` 都没有**
* (JS 从没跑起来)。
*
* ## 做法
* 用 Kotlin 复刻 `ktv_api.js` 的同一套协议:
* `GET <host>/i.php`(取 token)→ `GET <host>/music/do.php`(取播放地址),
* 并镜像 JS 的多源排序(失败源沉底)与广告/错号资源校验(`isDemoUrl`)。
*
* ⚠️ 它**必须走与 JS 相同的 HTTP 通道**([KtvJsBridge.rawCleanGet] 的裸 Socket,
* 只发 Host + Accept),否则会被源站风控拒绝(带 UA/Connection 头会被判"异常行为")。
*
* ⚠️ 这里的 APP_ID/APP_KEY/SDK_KEY/HOSTS 是 `ktv_api.js` CONFIG 的镜像。
* 源站配置若变更,JS 可热更新而这里需要发版 —— 因此它**只作为兜底**:
* WebView 可用时仍以 JS 为准(见 [SongApiClient.getSongDownloadUrl])。
*/
object SongUrlResolver {
private const val TAG = "SongUrlResolver"
// ── 以下与 assets/mobile/ktv_api.js 的 CONFIG 保持一致 ──
// 注意 VER/VN 与 KtvJsBridge 注入 JS 时覆盖的值一致(不是 JS 文件里的原始值)。
private const val APP_ID = "d4eeacc6cec3434fbc8c41608a3056a0"
private const val APP_KEY = "024210cba40d4385a93e6c2d3249bfb5"
private const val SDK_KEY = "19042303a8374f67ae3fe1e25c97936f"
private const val VER = "2.0"
private const val VN = "4.1.3.03281430"
private const val TIMEOUT_MS = 8_000
private val DEFAULT_HOSTS = listOf(
"http://w.w345.my",
"http://desktok.top",
"http://mm.kk456.top",
"http://w.w345.my:66",
"http://z.w345.my",
"http://b.sktv.top",
"http://w345.my",
)
/** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */
private val hostOrder: MutableList<String> = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList())
@Volatile private var fetch: ((String) -> String?)? = null
@Volatile private var sn: String = KtvJsBridge.DEVICE_SN
/**
* 最近一次取址命中的源 host。
* 供 `SongApiClient.lastSongSourceHost()` 使用 —— 走原生兜底时 JS 的
* `reportSongResult` 不会触发,否则日志里「请求源=」永远是空的。
*/
@Volatile
var lastHost: String = ""
private set
/**
* 安装取址通道。
* @param fetch 干净的 HTTP GET(应传 [KtvJsBridge.rawCleanGet]);
* 入参 url,返回 `{"status":..,"body":..,"error":..}` 的 JSON 字符串。
*/
fun install(fetch: (String) -> String?) {
this.fetch = fetch
Log.i(TAG, "native song url resolver installed (${DEFAULT_HOSTS.size} hosts)")
}
private fun demote(host: String) {
synchronized(hostOrder) {
val index = hostOrder.indexOf(host)
if (index >= 0 && index < hostOrder.size - 1) {
hostOrder.removeAt(index)
hostOrder.add(host)
}
}
}
private fun promote(host: String) {
synchronized(hostOrder) {
val index = hostOrder.indexOf(host)
if (index > 0) {
hostOrder.removeAt(index)
hostOrder.add(0, host)
}
}
}
/** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */
fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? {
if (musicno.isBlank()) return null
val channel = fetch ?: run {
MiliLog.warn("NATIVE-SONG 未安装取址通道,跳过原生兜底")
return null
}
val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn)
val startedAt = System.currentTimeMillis()
val hosts = synchronized(hostOrder) { hostOrder.toList() }
var lastDetail = "no-source"
for ((index, host) in hosts.withIndex()) {
val token = runCatching { fetchToken(channel, host, mac) }.getOrElse {
MiliLog.warn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null
}
if (token.isNullOrEmpty()) {
lastDetail = "token-fail"
demote(host)
continue
}
val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) }
.getOrElse {
MiliLog.warn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null
}
if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) {
promote(host)
lastHost = host
val elapsed = System.currentTimeMillis() - startedAt
val urlHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "?"
MiliLog.info(
"NATIVE-SONG 取址成功 编号=$musicno 源=$host urlHost=$urlHost " +
"第${index + 1}个源 用时=${elapsed}ms",
)
return url
}
lastDetail = when {
url.isNullOrEmpty() -> "no-url"
else -> "demo-or-mismatch"
}
demote(host)
}
val elapsed = System.currentTimeMillis() - startedAt
MiliLog.error("NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms")
return null
}
// ─── 协议实现 ───────────────────────────────────────
private fun fetchToken(channel: (String) -> String?, host: String, mac: String): String? {
val ts = System.currentTimeMillis() / 1000
val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN"
val sign = md5(params + APP_KEY)
val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null
if (json.optInt("code") != 200) {
MiliLog.warn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}")
return null
}
return json.optString("token").takeIf { it.isNotEmpty() }
}
private fun fetchSongUrl(
channel: (String) -> String?,
host: String,
mac: String,
musicno: String,
token: String,
resolution: String,
h265: Boolean,
): String? {
val ts = System.currentTimeMillis() / 1000
// 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 一致(同 ktv_api.js)。
val params = "appid=$APP_ID&device=${mac}_$sn&ish265=${if (h265) "1" else "0"}&ls=1" +
"&musicno=$musicno&resolution=$resolution&sn=$sn&time=$ts&token=$token"
val sign = md5(params + SDK_KEY)
val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null
val code = json.optInt("code")
if (code != 200) {
MiliLog.warn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}")
return null
}
return json.optString("data").takeIf { it.isNotEmpty() }
}
/** 解析 request() 的信封 `{status, body, error}`,再把 body 解析为 JSON。 */
private fun getJson(channel: (String) -> String?, url: String): JSONObject? {
val raw = channel(url) ?: return null
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val status = envelope.optInt("status")
if (status !in 200..299) {
MiliLog.warn(
"NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " +
"err=${envelope.optString("error").take(80)}",
)
return null
}
val body = envelope.optString("body")
if (body.isBlank()) return null
return runCatching { JSONObject(body) }.getOrElse {
MiliLog.warn("NATIVE-SONG 响应非 JSON: ${body.take(120)}")
null
}
}
/**
* 原生路径的可信校验:先过 `AdVideoFilter.isTrustedSongUrl`(广告域名/路径 + `.ts` 文件名
* 必须等于点歌编号),再补上 JS `isDemoUrl` 里那几条(wb66/demo、纯 IP、R2 测试桶)。
*/
private fun isValidSongUrl(url: String, musicno: String): Boolean {
if (!AdVideoFilter.isTrustedSongUrl(url, musicno)) return false
val lower = url.lowercase()
if (lower.contains("wb66") || lower.contains("demo")) return false
if (IP_URL.containsMatchIn(url)) return false
if (R2_DEMO.containsMatchIn(url)) return false
return true
}
private fun md5(text: String): String =
MessageDigest.getInstance("MD5").digest(text.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
private val IP_URL = Regex("^https?://\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(?::\\d+)?/", RegexOption.IGNORE_CASE)
private val R2_DEMO = Regex("pub-[a-z0-9]+\\.r2\\.dev/ss/", RegexOption.IGNORE_CASE)
private val TS_NAME = Regex("/(\\d+)\\.ts(?:\\?|$)")
}
@@ -0,0 +1,186 @@
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.SSLSocketFactory
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
/** 「系统信任 ∪ 内置根证书」的 SSLSocketFactory,供裸 Socket 通道复用。 */
@Volatile private var patchedFactory: SSLSocketFactory? = null
/**
* 给**裸 Socket** 通道用的 SSLSocketFactory。
*
* ⚠️ 必须单独提供:`HttpsURLConnection.setDefaultSSLSocketFactory` **不会**影响
* `SSLSocketFactory.getDefault()`,而 `KtvJsBridge.rawRequest()` 的 HTTPS 分支正是用后者
* (`network_security_config` 也只作用于 HttpsURLConnection / WebView,管不到裸 socket)。
* 未初始化时回退系统默认。
*/
fun sslSocketFactory(): SSLSocketFactory =
patchedFactory ?: (SSLSocketFactory.getDefault() as SSLSocketFactory)
/** 主机 → 是否已被增强信任库校验通过(只缓存肯定结果)。 */
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
}
val composite = CompositeTrustManager(system, bundled)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf<TrustManager>(composite), null)
val factory = sslContext.socketFactory as SSLSocketFactory
// 裸 Socket 通道始终使用它(系统信任 + 内置根证书,二者取并集)。
patchedFactory = factory
if (android.os.Build.VERSION.SDK_INT >= 24) {
// 新系统由 network_security_config 负责 HttpsURLConnection,无需接管默认工厂。
Log.i(TAG, "API>=24, keep system default for HttpsURLConnection")
return
}
HttpsURLConnection.setDefaultSSLSocketFactory(factory)
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())
}
}
@@ -0,0 +1,66 @@
package com.local.ktv;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* 广告地址拦截的回归测试。
*
* 对应 2026-09-26 现场事故:新设备(WebView 正常 → 走 JS 取址)点歌 7303235(情歌 HD)
* 拿到 `gz.ac16.vip` 的广告视频,被当成歌曲下载并播放。当时的漏洞有三处:
* 1. JS 的 isDemoUrl 不认识该广告域名,且只比对"纯数字文件名",非数字名字直接放行;
* 2. Kotlin 的 buildDownloadUrl 成功分支**完全没做广告校验**;
* 3. DB 回退路径只做 isLikelyDemoUrl,也挡不住广告域名。
* 这里用日志里的**真实 URL 形态**锁住入参校验行为。
*/
public final class AdVideoFilterTest {
/** 真源地址:两个 CDN 域实测文件名都严格等于点歌编号,必须放行。 */
@Test
public void acceptsRealSourceUrls() {
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://txog.ktvsky.com/264/lvbr/480/E/ts/47.1/202209220333/480p/7303235.ts?token=526e505e",
"7303235"));
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://download.origjoy.com/264/lvbr/480/E/ts/39.6/202309070206/480p/7445418.ts?token=abc",
"7445418"));
}
/** 广告域名:即使文件名伪装成正确的点歌编号,也必须拦下。 */
@Test
public void rejectsAdHostEvenWithMatchingName() {
assertFalse(AdVideoFilter.isTrustedSongUrl("http://gz.ac16.vip/ad/7303235.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("http://cdn.ac16.vip/x/7303235.ts", "7303235"));
}
/** 广告文件名的经典形态:非数字名字,旧规则的正则匹配不到。 */
@Test
public void rejectsAdFileName() {
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/ad_files/my_ad_video.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/my_ad_video.ts", "7303235"));
}
/** .ts 文件名与点歌编号不符(即使不命中任何广告域名)也视为不可信。 */
@Test
public void rejectsMismatchedFileName() {
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/9999999.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/promo.ts", "7303235"));
}
/** 非 http(s) 一律不可信。 */
@Test
public void rejectsNonHttpSchemes() {
assertFalse(AdVideoFilter.isTrustedSongUrl("file:///sdcard/x/7303235.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("ftp://example.com/7303235.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl(null, "7303235"));
}
/** 没有 .ts 文件名时不做文件名比对(避免误杀非 ts 的真源)。 */
@Test
public void allowsNonTsUrlWithoutAdMarkers() {
assertTrue(AdVideoFilter.isTrustedSongUrl("http://cdn.example.com/path/stream?token=abc", "7303235"));
}
}