Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7a28fc45d | ||
|
|
1687f87eca | ||
|
|
691e56f617 | ||
|
|
0cffd08789 | ||
|
|
1ceba8ef7f | ||
|
|
a467719675 | ||
|
|
62216b0fb8 | ||
|
|
8ad2aa50d7 | ||
|
|
40d1fec719 | ||
|
|
96c83d27b0 |
@@ -34,11 +34,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 +68,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 +89,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,12 +160,118 @@ $BT/apksigner verify 目标名.apk
|
||||
|
||||
### 版本号
|
||||
|
||||
- `app/build.gradle`:`versionName "1.4.0"` / `versionCode 16`
|
||||
- `app/build.gradle`:`versionName "1.4.7"` / `versionCode 27`(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` 两处校验),否则设备仍用缓存旧脚本
|
||||
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
|
||||
- **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
|
||||
|
||||
+14
-5
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.local.ktv"
|
||||
minSdk 21
|
||||
targetSdk 25
|
||||
versionCode 16
|
||||
versionName "1.4.0"
|
||||
versionCode 27
|
||||
versionName "1.4.7"
|
||||
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等信息
|
||||
|
||||
@@ -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 {
|
||||
val target = MuseDatabase.defaultDbFile()
|
||||
val free = target.parentFile?.usableSpace ?: 0L
|
||||
// 完整异常链:老设备上「表面原因」常常误导(例如 ENOSPC 其实不是块空间不足)。
|
||||
val chain = generateSequence(error) { it.cause }
|
||||
.take(5)
|
||||
.joinToString(" <- ") { "${it.javaClass.simpleName}: ${it.message}" }
|
||||
val diagnose = runCatching { AppPaths.diagnose() }.getOrDefault("(diagnose failed)")
|
||||
MiliLog.warn("DB-SYNC download failed | $chain | $diagnose")
|
||||
Log.w(TAG, "Database download failed: $chain | $diagnose")
|
||||
|
||||
if (isNoSpaceError(error)) {
|
||||
val target = MuseDatabase.defaultDbFile()
|
||||
// ⚠️ 不能只看异常消息就断言"空间不足":inode 耗尽 / 配额 / ROM 限制同样报 ENOSPC,
|
||||
// 而 usableSpace 可能还显示好几 GB(本机实测:报空间不足时可用 6.04GB)。
|
||||
// 用真实写入探针区分"真的写不进去" vs "其实能写"。
|
||||
val probe = runCatching { AppPaths.probeWrite(target.parentFile!!) }.getOrDefault("?")
|
||||
return IllegalStateException(
|
||||
"曲库分区空间不足(当前可用 ${formatSize(target.parentFile?.usableSpace ?: 0L)})," +
|
||||
"已清理下载临时文件,请释放空间后重试",
|
||||
"曲库写入被系统拒绝(探针 $probe,可用 ${formatSize(free)})\n原始错误:$chain",
|
||||
error,
|
||||
)
|
||||
}
|
||||
val message = error.message?.takeIf { it.isNotBlank() } ?: "曲库下载失败"
|
||||
Log.w(TAG, "Database download failed: $message")
|
||||
return IllegalStateException(message, error)
|
||||
return IllegalStateException("曲库下载失败:$chain(可用 ${formatSize(free)})", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
package com.local.ktv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.liulishuo.okdownload.OkDownload
|
||||
|
||||
class KtvApplication : Application() {
|
||||
/**
|
||||
* 存储根目录必须在**任何** AppPaths 访问之前解析好:
|
||||
* 部分定制盒子(极米 H1S / Android 5.1.1)上 `/storage/emulated/0` 不可写,
|
||||
* 历史实现直接崩溃。这里用 attachBaseContext 抢在所有业务代码之前完成探测。
|
||||
*/
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
base?.let {
|
||||
runCatching { AppPaths.init(it) }
|
||||
.onFailure { error -> Log.e(TAG, "AppPaths.init failed", error) }
|
||||
// 老系统(API<24)不读 network_security_config,需要代码补上内置根证书的信任,
|
||||
// 否则 Let's Encrypt 新链的接口(曲库同步/点歌登录/JS 热更新)全部握手失败。
|
||||
runCatching { TlsCompat.install(it) }
|
||||
.onFailure { error -> Log.e(TAG, "TlsCompat.install failed", error) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instanceRef = this
|
||||
|
||||
@@ -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
|
||||
@@ -46,7 +49,7 @@ class KtvJsBridge(context: Context) {
|
||||
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
|
||||
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
|
||||
private val DEVICE_MAC: String get() = DeviceMac.effective()
|
||||
private const val DEVICE_SN = "2d7bb4a7d7f7"
|
||||
internal const val DEVICE_SN = "2d7bb4a7d7f7"
|
||||
|
||||
/**
|
||||
* 断网重连后重置歌曲源会话。
|
||||
@@ -95,6 +98,7 @@ class KtvJsBridge(context: Context) {
|
||||
updateCache(cached)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "WebView init failed", error)
|
||||
MiliLog.error("JS-BRIDGE WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}")
|
||||
callback(false)
|
||||
}
|
||||
}
|
||||
@@ -108,11 +112,34 @@ 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
|
||||
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 +148,9 @@ class KtvJsBridge(context: Context) {
|
||||
createWebView(readAssetScript(), callback)
|
||||
} else {
|
||||
Log.e(TAG, "KtvApi was not exported by JS")
|
||||
// 老电视盒子上 WebView 不可用/被裁剪时常走这里:桥永远不就绪,
|
||||
// 取址只能回退 DB → 「无法生成下载地址」。必须能远程看到。
|
||||
MiliLog.error("JS-BRIDGE KtvApi 未导出,WebView 不可用")
|
||||
callback(false)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +163,18 @@ class KtvJsBridge(context: Context) {
|
||||
/** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */
|
||||
fun isReady(): Boolean = ready && !destroyed
|
||||
|
||||
/**
|
||||
* 供**原生兜底取址解析器**([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
|
||||
|
||||
/**
|
||||
@@ -271,7 +313,7 @@ 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" }
|
||||
@@ -305,12 +347,32 @@ 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.log 桥接:ktv_api.js 的 _log()/_log 细节(Try source / Token FAIL / do.php code=
|
||||
// 等)原本只进 WebView 控制台,远程日志里完全看不到,点歌失败无从定位。
|
||||
// 这里把它转发到 android.log → MiliLog,并保留原始 console 行为。
|
||||
val consoleBridge = """
|
||||
(function(){
|
||||
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 +413,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 +488,22 @@ 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 新根握手失败。
|
||||
(TlsCompat.sslSocketFactory().createSocket() as SSLSocket).apply {
|
||||
connect(java.net.InetSocketAddress(uri.host, port), connectTimeout)
|
||||
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 +544,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 +588,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -426,7 +425,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 +470,8 @@ class MainActivity : AppCompatActivity() {
|
||||
playbackEngine = KtvPlaybackEngine(applicationContext)
|
||||
// 远程日志系统(GlogCenter): 启动即上报设备/网络信息。
|
||||
MiliLog.init(applicationContext)
|
||||
// 存储根目录探测结果(排查不同盒子存储差异用;单行便于按 STORAGE 检索)。
|
||||
MiliLog.info("STORAGE ${AppPaths.storageFacts(this)}")
|
||||
refreshLogDeviceContext()
|
||||
reportDeviceBootInfo()
|
||||
// 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403,
|
||||
@@ -534,7 +539,7 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun registerMicInputWatcher() {
|
||||
val manager = audioManager ?: return
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
manager.registerAudioDeviceCallback(micDeviceCallback, null)
|
||||
manager.registerAudioDeviceCallback(ensureMicDeviceCallback(), null)
|
||||
} else {
|
||||
runCatching {
|
||||
val filter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
|
||||
@@ -546,20 +551,37 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun unregisterMicInputWatcher() {
|
||||
val manager = audioManager ?: return
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
manager.unregisterAudioDeviceCallback(micDeviceCallback)
|
||||
micDeviceCallback?.let { manager.unregisterAudioDeviceCallback(it) }
|
||||
micDeviceCallback = null
|
||||
} else {
|
||||
runCatching { unregisterReceiver(micHotplugReceiver) }
|
||||
}
|
||||
}
|
||||
|
||||
private val micDeviceCallback = object : android.media.AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) })
|
||||
}
|
||||
/**
|
||||
* 输入设备变化回调(`android.media.AudioDeviceCallback`, API 23+)。
|
||||
*
|
||||
* ⚠️ **必须按需创建,不能写成字段初始化器**:字段初始化在 Activity 构造时执行,
|
||||
* 早于 `onCreate`,而 `Build.VERSION.SDK_INT` 判断发生在 `onCreate` 之后。
|
||||
* Android 5.x(API 22) 没有 `AudioDeviceCallback` 这个类 —— 对象在构造期被实例化会直接
|
||||
* `NoClassDefFoundError` 闪退(极米 H1S / GMUI2.0 / Android 5.1.1 的闪退根因)。
|
||||
*/
|
||||
private var micDeviceCallback: android.media.AudioDeviceCallback? = null
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
|
||||
@TargetApi(23)
|
||||
private fun ensureMicDeviceCallback(): android.media.AudioDeviceCallback {
|
||||
micDeviceCallback?.let { return it }
|
||||
val created = object : android.media.AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) })
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
|
||||
}
|
||||
}
|
||||
micDeviceCallback = created
|
||||
return created
|
||||
}
|
||||
|
||||
private val micHotplugReceiver = object : BroadcastReceiver() {
|
||||
@@ -568,7 +590,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 +615,7 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun initializeDatabase() {
|
||||
if (databaseBootstrapRunning) return
|
||||
databaseBootstrapRunning = true
|
||||
lastDatabaseProgressText = ""
|
||||
showDatabaseLoading(true, "正在初始化曲库...", null)
|
||||
io.execute {
|
||||
var bootstrapError: Throwable? = null
|
||||
@@ -621,7 +645,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 +685,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 +1734,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 +3024,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))
|
||||
@@ -4179,7 +4225,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 +4681,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 +4695,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 +4750,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 + " 首")
|
||||
}
|
||||
@@ -5563,7 +5600,7 @@ class MainActivity : AppCompatActivity() {
|
||||
SongFileValidator.requiresTransportStream(file),
|
||||
).valid
|
||||
) {
|
||||
localSongs.putIfAbsent(stableId(song), song)
|
||||
localSongs.getOrPut(stableId(song)) { song }
|
||||
}
|
||||
}
|
||||
val allLocalSongs = localSongs.values.toList()
|
||||
@@ -5971,10 +6008,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 +8449,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 {
|
||||
@@ -8686,7 +8720,7 @@ class MainActivity : AppCompatActivity() {
|
||||
).valid
|
||||
) {
|
||||
song.path = resolved.absolutePath
|
||||
result.putIfAbsent(stableId(song), song)
|
||||
result.getOrPut(stableId(song)) { song }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8711,13 +8745,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 +8898,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) {
|
||||
|
||||
@@ -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,9 +24,13 @@ 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
|
||||
Log.i(TAG, "JS Bridge init: $ok")
|
||||
if (!ok) MiliLog.error("JS-BRIDGE 初始化返回 false,取址将走原生兜底")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +51,10 @@ object SongApiClient {
|
||||
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? {
|
||||
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
Log.w(TAG, "Synchronous JS request on main thread; using DB fallback")
|
||||
return fetchFromDb(musicno)
|
||||
Log.w(TAG, "Synchronous JS request on main thread; using native/DB fallback")
|
||||
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
}
|
||||
val bridge = bridge ?: return fetchFromDb(musicno)
|
||||
val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
|
||||
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
|
||||
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
|
||||
@@ -62,8 +66,11 @@ object SongApiClient {
|
||||
}
|
||||
}
|
||||
if (!bridge.isReady()) {
|
||||
Log.w(TAG, "Bridge not ready after wait, DB fallback")
|
||||
return fetchFromDb(musicno)
|
||||
// ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是
|
||||
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器。
|
||||
Log.w(TAG, "Bridge not ready after wait, native resolver")
|
||||
MiliLog.warn("JS-BRIDGE 15s 未就绪,改用原生取址通道")
|
||||
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
}
|
||||
val result = AtomicReference<String?>()
|
||||
val latch = CountDownLatch(1)
|
||||
@@ -74,7 +81,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,9 +91,17 @@ 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)
|
||||
|
||||
@@ -32,12 +32,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()
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 安装取址通道。
|
||||
* @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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** 镜像 ktv_api.js 的 `isDemoUrl`:广告视频 / 纯 IP / R2 测试桶 / ts 编号与请求不一致。 */
|
||||
private fun isValidSongUrl(url: String, musicno: String): Boolean {
|
||||
if (AdVideoFilter.isAdVideo(url)) 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
|
||||
val matched = TS_NAME.find(url)?.groupValues?.getOrNull(1)
|
||||
if (matched != null && matched != musicno) 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user