Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40a2bdf9b | ||
|
|
fad2599e63 | ||
|
|
eadc2d4de1 | ||
|
|
669766ba60 | ||
|
|
b3b17210f1 | ||
|
|
906fa210e4 | ||
|
|
a7a28fc45d | ||
|
|
1687f87eca | ||
|
|
691e56f617 | ||
|
|
0cffd08789 | ||
|
|
1ceba8ef7f | ||
|
|
a467719675 | ||
|
|
62216b0fb8 | ||
|
|
8ad2aa50d7 | ||
|
|
40d1fec719 | ||
|
|
96c83d27b0 |
@@ -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
@@ -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等信息
|
||||
|
||||
@@ -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](替换);
|
||||
* 非广告地址原样返回。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
if (isNoSpaceError(error)) {
|
||||
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)) {
|
||||
// ⚠️ 不能只看异常消息就断言"空间不足":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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +605,27 @@ 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() {
|
||||
/**
|
||||
* 输入设备变化回调(`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
|
||||
|
||||
@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) })
|
||||
}
|
||||
@@ -561,6 +634,9 @@ class MainActivity : AppCompatActivity() {
|
||||
handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
|
||||
}
|
||||
}
|
||||
micDeviceCallback = created
|
||||
return created
|
||||
}
|
||||
|
||||
private val micHotplugReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
@@ -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())
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user