# 麦动 KTV — 项目开发约定 > 本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。 ## 一、代码同步与发布策略(2026-09-14 更新) **代码主仓库:Gitea `https://gitea.kasugano.cn/zijunchiang/maidong-ktv`** | 远端 | 地址 | 用途 | |---|---|---| | `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 代码主仓库,**唯一的代码同步目标** | | `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | 📦 **不推代码**,只用于发布 Release;仓库内只保留 README 文档 + Releases 附件 | ### 四条硬性规则 1. **推送失败只重试 3 次**:Gitea 不可用(网络不通/服务器故障/返回非 200)时,最多重试 **3 次** (间隔约 60s)。3 次仍失败就**停止重试**,改动保留为本地 commit,**留到下次一起同步** (不要无限重试、不要为了"推上去"而改推 Gitee)。恢复同步时直接 `git push origin master` 把积压的 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`。不要在同一个版本号下反复改动交付。 4. **发布是手动操作**:**未收到明确的「发布新版本」指令时,默认只在本地打包测试**—— 产物按内部测试约定命名 `maidong-ktv-<版本>-<改动说明>-双ABI-release.apk`, **不创建 Release、不上传附件、不推送 tag、不在 Gitee 建 Release**。 只有收到发布指令后,才按「发布新版本」小节在 Gitea(以及需要的 Gitee)建 Release。 > ⚠️ **不要混淆"交付"与"发布"**:向 Gitea `git push origin master` 同步代码(规则 1)、 > 本地构建/签名出 `*-release.apk`(规则 4 前半段)**都不算发布**,不需要、也不得顺带 > 建 Release / 打 tag / 传附件。发布动作只有三个:建 Release、打 tag、上传附件; > 三者的触发条件只有一个 —— **收到明确的人工发布指令**。若不确定是否已发布, > 用 `GET /repos/{owner}/{repo}/releases` 与 `/tags` 核对,不要凭"打过包"推断。 - 凭据: - **Gitea**(代码主仓库):访问令牌 `dsh-sync-20260926`(2026-09-26 数据迁移后重建;可在 Gitea 「设置 → 应用 → 访问令牌」随时吊销),存放于 `~/.git-credentials-gitea`(权限 600), 由**按主机限定**的仓库级 `credential.https://gitea.kasugano.cn.helper` 引用; - **Gitee**(仅发布 Release,按规则 2 不推代码):访问令牌存放于 `~/.git-credentials-gitee` (权限 600),由全局 `credential.https://gitee.com.helper` 引用;**只用于 API 建 Release / 传附件**,不得用于推送源码。 - 账号密码不写入仓库配置。 - ⚠️ **两个 helper 都必须按主机限定作用域**(`credential.https://.helper`,不要用无主机限定的 `credential.helper`):git 认证成功后会调用**每个** helper 的 `store`,无主机限定会把 Gitee 凭据 追加写进 Gitea 的凭据文件(一个文件两行),此后「取第一行」式的令牌提取会拿到错误令牌、API 报 401。 - ⚠️ **提取令牌必须按主机过滤**:用 `sed -nE 's|https://[^:]+:([^@]+)@.*|\1|p'`, 不要用不带主机锚点的 `sed -E 's|https://[^:]+:([^@]+)@.*|\1|'`(多行文件会输出多行、拼成错令牌)。 ### 推送方式 ```bash cd /home/zijun/dsh_data/maidongktv/maidong-ktv git push origin master # origin = Gitea(唯一的代码同步目标) ``` > Gitee 不参与代码同步;确需回推文档时用 `git push --no-verify gitee master`, > 但按规则 2 正常情况下不应执行。 ### 运行时端点(已全部迁移到 Gitea) App 内部两处端点已指向 Gitea(随本版 APK 生效): - `AppUpdateManager.kt`:`gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest`(App 内检查更新) - `KtvJsBridge.kt`:`gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js`(JS 热更新) > 注意:Gitea 的 raw 规范路径是 `/raw/branch/<分支>/<文件>`;`/raw/<分支>/<文件>` 会 303 跳转。 > Gitea 的 raw 与 release 下载对**无 User-Agent** 的请求同样放行(App 裸 socket 可直连)。 ### 发布新版本(Gitea Release) ```bash 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" \ -d '{"tag_name":"v1.2.6","name":"麦动KTV V1.2.6","body":"更新说明","target_commitish":"master"}' \ https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases # 2) 上传 APK 附件(用返回的 release id;附件名必须 ASCII) curl -X POST -u "zijunchiang:$TOKEN" \ -F "attachment=@麦动KTV-V1.2.6-release.apk" \ "https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases//assets?name=maidong-ktv-V1.2.6-release.apk" ``` > ⚠️ **附件文件名必须为 ASCII**:Gitea 1.27 的规范下载路径 > `/releases/download//<文件名>` 对非 ASCII 文件名返回 404(`/attachments/` 直链虽可用, > 但 `releases/latest` API 返回的是规范路径)。因此 Gitea 上的附件用 > `maidong-ktv-V1.2.x-release.apk` 这类 ASCII 名;本地留存的发布包仍可命名 > `麦动KTV-V1.2.x-release.apk`。 > > 上传 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//attach_files" ``` > Gitee 附件名**可用中文**(ASCII 限制只针对 Gitea 1.27),上传 22MB 约 4s。 > 发布后务必校验下载:`curl -sL | md5sum` 应与本地发布包一致 > (Gitea 侧同样要校验 `/releases/download//<文件>` 规范路径返回 200)。 ## 二、构建与产物 ### 构建(release,双 ABI) ```bash cd /home/zijun/dsh_data/maidongktv/maidong-ktv . /home/zijun/dsh_data/maidongktv/env.sh # 导入 JAVA_HOME / ANDROID_HOME / PATH ./gradlew assembleRelease ``` 产物:`app/build/outputs/apk/release/app-release.apk`(已由 `app/build.gradle` 的 signingConfig 签名) ### 本机工具链(2026-09-26 数据迁移后重建) 数据迁移后的本机是**纯净环境且当前用户无 sudo**,因此工具链全部装在用户态, 不要再用系统路径(`/usr/lib/jvm/...` 不存在)。统一入口: ```bash . /home/zijun/dsh_data/maidongktv/env.sh # 已加入 ~/.bashrc,新开 shell 自动生效 ``` | 组件 | 路径 | 来源 | |---|---|---| | JDK 17 | `/home/zijun/tools/jdk-17`(Temurin 17.0.20.1) | Adoptium tarball 解包 | | git | `/home/zijun/tools/bin/git`(包装脚本 → `/home/zijun/tools/gitroot/usr/bin/git`) | `apt-get download git` + `dpkg -x` | | Android SDK | `/home/zijun/Android/Sdk`(与 `local.properties` 的 `sdk.dir` 一致) | `cmdline-tools` + `sdkmanager` | | 已装 SDK 包 | `platforms;android-35`、`build-tools;34.0.0`、`build-tools;36.0.0`、`platform-tools` | sdkmanager | | 签名密钥 | `/home/zijun/dsh_data/maidongktv/ktv_keystore.jks`(仓库外,勿入库) | 数据迁移 | - `git` 包装脚本是必需的:deb 内 git 的编译期 `exec-path` 写死为 `/usr/lib/git-core` (无 root 无法创建),包装脚本显式设置 `GIT_EXEC_PATH`/`GIT_TEMPLATE_DIR` 后才找得到 `git-remote-https` 等子命令。 - 迁移会把仓库文件权限改成 744(git 记为 1288 个纯 mode change、0 行内容变化)。 遇到大量 `mode change 100644 => 100755` 时,按索引恢复权限即可,不要当成内容改动: `git ls-files -s -z | ... chmod`(或临时 `git config core.fileMode false`)。 ### 签名与校验(单独签名/改名时) ```bash BT=/home/zijun/Android/Sdk/build-tools/36.0.0 $BT/apksigner sign --ks /home/zijun/dsh_data/maidongktv/ktv_keystore.jks \ --ks-key-alias ktv --ks-pass pass:123456 --key-pass pass:123456 \ --out 目标名.apk app/build/outputs/apk/release/app-release.apk $BT/apksigner verify 目标名.apk ``` - 密钥:`/home/zijun/dsh_data/maidongktv/ktv_keystore.jks`(仓库外,勿入库) - APK 命名约定:`麦动KTV-V<版本号>-release.apk`(对外发布)/ `maidong-ktv-<版本>-<改动说明>-双ABI-release.apk`(内部测试) ### 版本号 - `app/build.gradle`:`versionName "1.4.6"` / `versionCode 26`(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. 新增任何"周期性/高频"回调(进度、心跳、轮询)时都要按同样标准评估主线程开销。 - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底) - **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 - **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载) - **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建 - **设备 MAC**:统一由 `DeviceMac` 解析**当前在用网卡**的硬件地址(12 位小写十六进制)。优先 `wlan*`/`eth*`/`en*`,忽略虚拟/蜂窝/VPN 接口;Android 6 起 `WifiInfo.getMacAddress()` 恒为 `02:00:00:00:00:00`,因此直接读 `NetworkInterface.getHardwareAddress()`;读出 `02:00:00:00:00:00` 或全 0 视为无效。读取失败回退 `DeviceMac.FALLBACK_MAC`(历史抓包值),保证身份永不为空。 结果按进程缓存,网络切换时由 `remoteNetworkMonitor` 调用 `DeviceMac.refresh()` 重解析。 消费方:`KtvJsBridge.DEVICE_MAC`(JS 端 `mac`)、whoami 心跳的 `_mac`/`androidid`/`_cid` (抓包时三者同值,保持一致) - **播放信息实时刷新**:`refreshNowPlayingLabels()` 是播放界面左上角「当前播放 / 下一首」、首页播放条、 旧版播放区与顶部状态栏的**唯一**刷新入口,只在文本变化时写 View;已挂到 `lyricTicker`(500ms)上, 因此队列变化(本地点歌 / 扫码点歌 / 自动切歌)最迟 500ms 内必然同步。新增播放信息展示时请复用它, 不要另起刷新路径 - **远程日志**:接入 GlogCenter 集中式日志(HTTP 接口,单条 JSON)。**域名属敏感信息,不得写入任何文档** (端点常量见代码 `MiliLog.ENDPOINT`,需要时查代码,勿在 README/AGENTS 中记录域名)。字段: `system`/`date`/`text`/`servername`/`serverip`/`clientip`/`loglevel`/`traceid`/`user`;`date` 必须为 `yyyy-MM-dd HH:mm:ss.SSS`,否则按时间检索异常。其中 `serverip` = **主机 IP**(公网出口 IP, 由 `MiliLog.PUBLIC_IP_URL` 查询,结果缓存 5 分钟、失败 1 分钟后重试,查询在独立后台线程, 绝不在主线程发起),`clientip` = **客户端 IP**(设备当前网卡获取到的局域网 IP,由 `MiliLog.updateDeviceContext(ip = ...)` 注入,网络变化时自动刷新并顺带复核公网 IP)。 客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error` 保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、 设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率 - **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询) 维护全局 `online`;`MainActivity.onNetworkStateChanged()` 负责: - 断网 → 播放页右上角 `fullScreenNetworkBadge` 显示「网络未连接」(在全屏控制层 header 内、 标题右侧,不遮挡右上角二维码); - 恢复 → `refreshNetworkState()`:重解析网卡 MAC + 日志上下文、**重建歌曲源会话**、 复位 `Eac19WindowGuard`、重启本地点歌服务并刷新二维码、自动重试断网期间失败的下载(最多 8 首)。 换网/换 IP 时 `remoteNetworkMonitor` 走同一个 `refreshNetworkState()`(静默,不弹提示)。 ⚠️ **「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因**: `SongApiClient.clearTokenCache()` 原本是**空实现**,JS 侧 `window.api.tokens` 与 `_apiInitPromise` 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上, 断网/重连(尤其换出口 IP)后旧 token 已失效 —— 取址这一步看起来成功,真正下载却 403/失败。 现在 `clearTokenCache()` 会真正下发 `KtvJsBridge.resetApiSession()` (置空 `window.api` 与 `_apiInitPromise`,下次取址重新构造 API 实例、回到默认源顺序并重新登录)。 下载层的网络类失败由 `SongOkDownloadManager` 结合 `KtvNetworkMonitor.online` 与异常类型/HTTP 0 判定并记入 `networkFailed`,网络恢复后由 `retryNetworkFailed()` 自动重试(手动取消的不入集合)。 ⚠️ 顺带修掉一个隐患:离线时 `localIp()` 为空,原 `remoteNetworkMonitor` 会每 5s 误判「IP 变化」 而反复重启本地点歌服务(并连带触发刷新);现已加空值短路,离线只等网络回调。 - **播放界面右上角二维码**:跟随全屏控制层一起出现/隐藏 —— 在 `setFullScreenChromeVisible()` 里同步 `fullScreenQrOverlay` 的可见性,`updateFullScreenQrOverlay()` 新建时按 `fullScreenChromeVisible` 初始化。**不要**再按「手机是否在点歌」自动隐藏 (原 `qrAutoHideMonitor` 已删除,扫码弹窗里的对应提示文案也一并删除) - **远程页面滑杆(触控)**:`` **绝不能被 `