Compare commits
7
Commits
57341280e4
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914450bc6e | ||
|
|
2779237408 | ||
|
|
b3b8e6a12d | ||
|
|
b37dbec443 | ||
|
|
4c792361a4 | ||
|
|
ba31caaa38 | ||
|
|
21eca10ee2 |
@@ -2,33 +2,62 @@
|
||||
|
||||
> 本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。
|
||||
|
||||
## 一、代码同步策略(重要,2026-09-11 起生效)
|
||||
## 一、代码同步策略(2026-09-11 更新)
|
||||
|
||||
**自动推送 Gitee:已禁用。**
|
||||
**代码主仓库已迁移至 Gitea:https://gitea.kasugano.cn/zijunchiang/maidong-ktv**
|
||||
|
||||
- ✅ 允许:本地 `git add` / `git commit`(保留本地提交历史,便于回滚与对比)
|
||||
- ❌ 禁止:**自动执行 `git push`**(包括"完成改动后顺手推送"这类默认动作)
|
||||
- ⏸ 仅在用户**明确指示**(例如"推送到 Gitee"/"同步代码")时,才执行推送
|
||||
| 远端 | 地址 | 状态 |
|
||||
|---|---|---|
|
||||
| `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 主仓库,**同步目标** |
|
||||
| `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | ⛔ 已停用(推送被钩子拦截) |
|
||||
|
||||
### 手动启用/推送方式
|
||||
- ✅ 预期动作:完成改动 → 本地 `git commit` → `git push origin master`(同步到 Gitea)
|
||||
- ⛔ 禁止:推送到 Gitee(`pre-push` 钩子会拦截,避免误推送)
|
||||
- 凭据:Gitea 访问令牌 `dsh-sync`(可在 Gitea「设置 → 应用 → 访问令牌」随时吊销),
|
||||
存放于 `~/.git-credentials-gitea`(权限 600),由本仓库级 `credential.helper` 引用;
|
||||
账号密码不写入仓库配置。
|
||||
|
||||
已安装 `pre-push` 钩子做拦截(防止误推送)。启用推送二选一:
|
||||
### 推送方式
|
||||
|
||||
```bash
|
||||
# 方式 1:创建启用标记(推荐,推送后建议删除标记恢复拦截)
|
||||
touch .git/ALLOW_GITEE_PUSH
|
||||
git push origin master
|
||||
rm -f .git/ALLOW_GITEE_PUSH
|
||||
|
||||
# 方式 2:临时绕过钩子
|
||||
git push --no-verify origin master
|
||||
cd /home/zijun/dsh/maidongktv/maidong-ktv
|
||||
git push origin master # origin = Gitea(主仓库)
|
||||
git push --no-verify gitee master # 仅在确需回推 Gitee 时使用
|
||||
```
|
||||
|
||||
### 远端信息
|
||||
### 运行时端点(已全部迁移到 Gitea)
|
||||
|
||||
- 仓库:https://gitee.com/zijunchiang/maidong-ktv (公开)
|
||||
- 分支:`master`
|
||||
- 发布:Gitee Release(tag 形如 `v1.2.5`,需附 `.apk` 附件,供 App 内检查更新)
|
||||
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 -E 's|https://zijunchiang:([^@]+)@.*|\1|' ~/.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/<id>/assets?name=maidong-ktv-V1.2.6-release.apk"
|
||||
```
|
||||
|
||||
> ⚠️ **附件文件名必须为 ASCII**:Gitea 1.27 的规范下载路径
|
||||
> `/releases/download/<tag>/<文件名>` 对非 ASCII 文件名返回 404(`/attachments/<uuid>` 直链虽可用,
|
||||
> 但 `releases/latest` API 返回的是规范路径)。因此 Gitea 上的附件用
|
||||
> `maidong-ktv-V1.2.x-release.apk` 这类 ASCII 名;本地留存的发布包仍可命名
|
||||
> `麦动KTV-V1.2.x-release.apk`。
|
||||
>
|
||||
> 上传 22MB APK 约需 70s,注意命令超时设置。
|
||||
|
||||
## 二、构建与产物
|
||||
|
||||
@@ -59,13 +88,20 @@ $BT/apksigner verify 目标名.apk
|
||||
|
||||
### 版本号
|
||||
|
||||
- `app/build.gradle`:`versionName "1.2.5"` / `versionCode 5`
|
||||
- `app/build.gradle`:`versionName "1.3.0"` / `versionCode 6`
|
||||
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
||||
|
||||
## 三、关键实现约束(避免踩坑)
|
||||
|
||||
- **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底)
|
||||
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
|
||||
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖 Gitee 数据库文件
|
||||
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
|
||||
- **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
|
||||
- **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
|
||||
- **发布**:Gitee 仓库必须保持 **public**,否则 App 内检查更新/下载会 403
|
||||
- **远程日志**:接入 GlogCenter 集中式日志(HTTP 接口,单条 JSON)。**域名属敏感信息,不得写入任何文档**
|
||||
(端点常量见代码 `MiliLog.ENDPOINT`,需要时查代码,勿在 README/AGENTS 中记录域名)。字段:
|
||||
`system`/`date`/`text`/`servername`/`serverip`/`loglevel`/`traceid`/`user`;`date` 必须为
|
||||
`yyyy-MM-dd HH:mm:ss.SSS`,否则按时间检索异常。客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),
|
||||
调用点 `MiliLog.info/notice/warn/error` 保持不变;设备 IP/标识通过 `MiliLog.updateDeviceContext()` 注入,
|
||||
网络变化时自动刷新
|
||||
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403)
|
||||
|
||||
+3
-1
@@ -59,6 +59,8 @@
|
||||
|
||||
## Project Notice
|
||||
|
||||
> **This repository**: [https://gitea.kasugano.cn/zijunchiang/maidong-ktv](https://gitea.kasugano.cn/zijunchiang/maidong-ktv)
|
||||
>
|
||||
> **UI/feature framework credit**: [https://gitee.com/yangyachao-X/maidong-ktv](https://gitee.com/yangyachao-X/maidong-ktv)
|
||||
>
|
||||
> This repository reworks the upstream "Maidong KTV" UI and feature framework, and the core/data layer was
|
||||
@@ -68,7 +70,7 @@
|
||||
Highlights of the reworked data layer:
|
||||
|
||||
- **Library**: synced exclusively from the official Muse service
|
||||
(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`); Gitee hosts code only, no database files.
|
||||
(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`); the code host carries source only, no database files.
|
||||
- **Song source**: `w.w345.my` (`i.php` token → `do.php` playback URL) with fixed device identity and
|
||||
reproduced signing; ad/mismatched resources (e.g. `wb66.cn/.../719.ts`) are blocked and retried.
|
||||
- **JS hot-update**: `app/src/main/assets/mobile/ktv_api.js`, requires the `KTV_BRIDGE_API: 7` marker.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
## 📌 项目说明
|
||||
|
||||
> **本仓库**:[https://gitea.kasugano.cn/zijunchiang/maidong-ktv](https://gitea.kasugano.cn/zijunchiang/maidong-ktv)
|
||||
>
|
||||
> **内容框架来源**:[https://gitee.com/yangyachao-X/maidong-ktv](https://gitee.com/yangyachao-X/maidong-ktv)
|
||||
>
|
||||
> 本仓库基于上游「麦动 KTV」的界面与功能框架进行改造,并使用 **DSH(DeepSeek Harness)** 重写内核与数据源:重新实现曲库同步、歌曲源接入、设备认证、风控应对、远程日志与广告屏蔽等底层逻辑。上游框架的版权归原作者所有,本项目仅作学习与技术交流。
|
||||
@@ -46,11 +48,11 @@
|
||||
|
||||
## 内核与数据源(DSH 重写部分)
|
||||
|
||||
- **曲库**:统一走**官方 Muse 同步**(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`,RSA 登录 + 增量文件校验),不再依赖 Gitee 承载数据库;Gitee 仓库仅存代码。
|
||||
- **曲库**:统一走**官方 Muse 同步**(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`,RSA 登录 + 增量文件校验),不再依赖代码托管仓库承载数据库。
|
||||
- **歌曲源**:接入 `w.w345.my`(`i.php` 取 token → `do.php` 解析播放地址),固定设备标识并复刻签名算法;广告/错号资源(如返回 `wb66.cn/.../719.ts` 或文件名与 musicno 不一致)会被拦截并重试。
|
||||
- **风控应对**:源对陌生出口 IP 存在试用窗口(约 10 分钟,403"检测到异常行为"),App 检测到 403 后弹窗提示更换公网 IP;同 token 退避重试,不放大请求量。
|
||||
- **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 7` 标记)。
|
||||
- **应用更新**:以 Gitee Release 标签作为版本号(如 `1.2.5`),Release 需附带一个 `.apk` 附件;仅当标签高于当前 `BuildConfig.VERSION_NAME` 时提示下载。
|
||||
- **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 8` 标记)。
|
||||
- **应用更新**:以 Gitea Release 标签作为版本号(如 `1.3.0`),Release 需附带一个 `.apk` 附件;仅当标签高于当前 `BuildConfig.VERSION_NAME` 时提示下载。
|
||||
|
||||
## 🔧 编译教程
|
||||
|
||||
@@ -130,7 +132,7 @@ adb install -r app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- **曲库初始化失败**:检查设备网络能否访问 `mconn.cherryonline.cn`;已在设置页提供「重置数据库」。
|
||||
- **点歌报 403 / 无播放地址**:多为歌曲源对当前公网 IP 的试用窗口到期,重启光猫/路由器更换公网 IP 后恢复。
|
||||
- **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 7` 标记,否则内置版本优先。
|
||||
- **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 8` 标记,否则内置版本优先。
|
||||
- **想改签名/ABI**:编辑 `app/build.gradle` 的 `signingConfigs` 与 `ndk.abiFilters` 后重新编译。
|
||||
|
||||
## 项目结构
|
||||
@@ -143,7 +145,7 @@ maidong-ktv/
|
||||
│ │ ├── MuseDbSync.kt # 官方曲库同步
|
||||
│ │ ├── MuseDatabase.kt # 曲库访问
|
||||
│ │ ├── KtvJsBridge.kt # JS 桥(HTTP/RSA/热更新)
|
||||
│ │ ├── MiliLog.kt # 远程 syslog 日志
|
||||
│ │ ├── MiliLog.kt # 远程日志上报(GlogCenter)
|
||||
│ │ └── SongOkDownloadManager.kt # 下载管理
|
||||
│ ├── src/main/assets/mobile/ktv_api.js # 歌曲源逻辑(热更新)
|
||||
│ └── src/main/res/ # 资源 / 证书
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
+2
-2
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.local.ktv"
|
||||
minSdk 21
|
||||
targetSdk 25
|
||||
versionCode 5
|
||||
versionName "1.2.5"
|
||||
versionCode 6
|
||||
versionName "1.3.0"
|
||||
ndk {
|
||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* KTV Song API - JS Hot Update Module
|
||||
* ====================================
|
||||
* 从 Gitee 热更新, App 无需发版即可修复 API 变更
|
||||
* 从 Gitea 热更新, App 无需发版即可修复 API 变更
|
||||
*
|
||||
* 用法:
|
||||
* const api = new KtvApi({proxy: 'http://127.0.0.1:7897'})
|
||||
|
||||
@@ -11,10 +11,10 @@ import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/** Gitee Release based in-app updater. */
|
||||
/** Gitea Release based in-app updater. */
|
||||
object AppUpdateManager {
|
||||
private const val LATEST_RELEASE_API =
|
||||
"https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/latest"
|
||||
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest"
|
||||
|
||||
data class Release(
|
||||
val version: String,
|
||||
@@ -54,7 +54,7 @@ object AppUpdateManager {
|
||||
apkName = apkName,
|
||||
)
|
||||
})
|
||||
}, "gitee-update-check").start()
|
||||
}, "gitea-update-check").start()
|
||||
}
|
||||
|
||||
fun download(
|
||||
@@ -105,7 +105,7 @@ object AppUpdateManager {
|
||||
require(partial.renameTo(target)) { "无法保存安装包" }
|
||||
target
|
||||
})
|
||||
}, "gitee-update-download").start()
|
||||
}, "gitea-update-download").start()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,14 +7,57 @@ import java.util.Locale
|
||||
/**
|
||||
* 曲库下载引导(仅官方 Muse 同步源)。
|
||||
*
|
||||
* 说明:已移除 Gitee 数据库分片/镜像下载路径(database_publish 不再发布到 Gitee,
|
||||
* Gitee 仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api)
|
||||
* 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类只做版本文件与可读错误包装。
|
||||
* 说明:已移除分片/镜像数据库下载路径(database_publish 不再发布到代码仓库,
|
||||
* 代码仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api)
|
||||
* 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类负责同步时效、版本文件
|
||||
* 与可读错误包装。
|
||||
*
|
||||
* 同步时效:默认 **30 天**([SYNC_TTL_DAYS])。未到期时启动不联网同步,直接用
|
||||
* 本地曲库;到期后才向服务器查询版本并在有更新时下载。
|
||||
*/
|
||||
object DatabaseBootstrapper {
|
||||
private const val TAG = "DatabaseBootstrapper"
|
||||
|
||||
/** 曲库同步时效(天):达到时效后才触发服务器同步。 */
|
||||
const val SYNC_TTL_DAYS = 30
|
||||
private const val SYNC_TTL_MS = SYNC_TTL_DAYS * 24L * 60 * 60 * 1000
|
||||
private const val DAY_MS = 24L * 60 * 60 * 1000
|
||||
|
||||
private val versionFile: File get() = File(MuseDatabase.defaultDbFile().parentFile, "db_version.txt")
|
||||
private val syncStampFile: File get() = File(MuseDatabase.defaultDbFile().parentFile, "db_sync_at.txt")
|
||||
|
||||
/** 上次成功同步(或成功校验为最新)的时间戳(毫秒);无记录返回 0。 */
|
||||
fun lastSyncAt(): Long = runCatching {
|
||||
syncStampFile.takeIf { it.exists() }?.readText()?.trim()?.toLongOrNull()
|
||||
}.getOrNull() ?: 0L
|
||||
|
||||
/** 记录一次成功同步/校验,作为 30 天时效的起点。 */
|
||||
fun markSynced(at: Long = System.currentTimeMillis()) {
|
||||
runCatching { syncStampFile.writeText(at.toString()) }
|
||||
}
|
||||
|
||||
/** 同步是否已到期;无记录(从未同步)视为到期。 */
|
||||
fun isSyncDue(now: Long = System.currentTimeMillis()): Boolean {
|
||||
val last = lastSyncAt()
|
||||
return last <= 0L || now - last >= SYNC_TTL_MS
|
||||
}
|
||||
|
||||
/** 距离下次同步的剩余毫秒;已到期或无记录返回 0。 */
|
||||
fun syncRemainingMs(now: Long = System.currentTimeMillis()): Long {
|
||||
val last = lastSyncAt()
|
||||
if (last <= 0L) return 0L
|
||||
return (last + SYNC_TTL_MS - now).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
/** 同步状态文案(用于设置界面展示)。 */
|
||||
fun syncStatusText(now: Long = System.currentTimeMillis()): String {
|
||||
val last = lastSyncAt()
|
||||
if (last <= 0L) return "未同步"
|
||||
val remaining = syncRemainingMs(now)
|
||||
if (remaining <= 0L) return "已到期"
|
||||
val days = (remaining + DAY_MS - 1) / DAY_MS
|
||||
return "$days 天后同步"
|
||||
}
|
||||
|
||||
/** 远端曲库版本(官方 Muse);失败返回 null。 */
|
||||
fun fetchRemoteVersion(): String? = MuseDbSync.fetchVersion()
|
||||
@@ -23,16 +66,12 @@ object DatabaseBootstrapper {
|
||||
versionFile.takeIf { it.exists() }?.readText()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}.getOrNull()
|
||||
|
||||
private fun saveLocalDbVersion(version: String) {
|
||||
runCatching { versionFile.writeText(version.trim()) }
|
||||
}
|
||||
|
||||
/** 下载曲库:仅官方 Muse 同步源。 */
|
||||
/** 下载曲库:仅官方 Muse 同步源;成功后记录同步时间(重置 30 天时效)。 */
|
||||
fun download(onProgress: (Int) -> Unit): Result<File> {
|
||||
return runCatching {
|
||||
val file = MuseDbSync.download(onProgress)
|
||||
// MuseDbSync 内部已保存版本;此处幂等兜底。
|
||||
MuseDbSync.fetchVersion()?.let(::saveLocalDbVersion)
|
||||
// MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。
|
||||
markSynced()
|
||||
file
|
||||
}.mapError(::readableError)
|
||||
}
|
||||
|
||||
@@ -30,15 +30,16 @@ import javax.crypto.Cipher
|
||||
* 在隐藏 WebView 中运行可热更新的歌曲接口脚本。
|
||||
*
|
||||
* JS 决定接口地址、参数、签名和响应解析;Android 只提供 HTTP 与 RSA 这两个
|
||||
* WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitee 上的 ktv_api.js。
|
||||
* WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitea 上的 ktv_api.js。
|
||||
*/
|
||||
class KtvJsBridge(context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "KtvJsBridge"
|
||||
private const val JS_FILE = "mobile/ktv_api.js"
|
||||
private const val CACHE_FILE = "ktv_api_cache.js"
|
||||
// 热更新脚本地址(Gitea 主仓库; raw 规范路径为 /raw/branch/<分支>/<文件>)
|
||||
private const val REMOTE_JS_URL =
|
||||
"https://gitee.com/zijunchiang/maidong-ktv/raw/master/app/src/main/assets/mobile/ktv_api.js"
|
||||
"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
|
||||
// 当前歌曲源 w.w345.my 使用的设备标识(mac + sn)。与用户抓包示例一致,
|
||||
@@ -250,7 +251,7 @@ class KtvJsBridge(context: Context) {
|
||||
cacheFile.writeText(script, Charsets.UTF_8)
|
||||
tempFile.delete()
|
||||
}
|
||||
Log.i(TAG, "JS cache updated from Gitee (${script.length} chars)")
|
||||
Log.i(TAG, "JS cache updated from Gitea (${script.length} chars)")
|
||||
// 启动后刚拉到的新版本直接生效;若已有请求在执行,则留到下次启动。
|
||||
mainHandler.post {
|
||||
if (ready && callbacks.isEmpty() && !destroyed) {
|
||||
|
||||
@@ -162,6 +162,8 @@ class MainActivity : AppCompatActivity() {
|
||||
startRemoteServer()
|
||||
updateMobileQrOverlay()
|
||||
updateFullScreenQrOverlay()
|
||||
// 网络/IP 变化后同步日志上报的 serverip。
|
||||
refreshLogDeviceContext()
|
||||
}
|
||||
main.postDelayed(this, 5000)
|
||||
}
|
||||
@@ -459,8 +461,9 @@ class MainActivity : AppCompatActivity() {
|
||||
// 初始化 API 客户端 (歌曲下载链接)
|
||||
init("abe235a87118f6de", "080027deed4f")
|
||||
playbackEngine = KtvPlaybackEngine(applicationContext)
|
||||
// 远程日志系统: 加载打包证书并后台连接 syslog 服务器, 启动即上报设备/网络信息。
|
||||
// 远程日志系统(GlogCenter): 启动即上报设备/网络信息。
|
||||
MiliLog.init(applicationContext)
|
||||
refreshLogDeviceContext()
|
||||
reportDeviceBootInfo()
|
||||
// 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403,
|
||||
// 换公网 IP 即恢复。到期时弹一次提示引导用户重启光猫/路由器(重拨换 IP)。
|
||||
@@ -586,23 +589,37 @@ class MainActivity : AppCompatActivity() {
|
||||
databaseBootstrapRunning = true
|
||||
showDatabaseLoading(true, "正在初始化曲库...", null)
|
||||
io.execute {
|
||||
val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
|
||||
val localVersion = DatabaseBootstrapper.getLocalDbVersion()
|
||||
val updateRequired = remoteVersion != null && remoteVersion != localVersion
|
||||
var bootstrapError: Throwable? = null
|
||||
var usedOldDatabase = false
|
||||
|
||||
var ok = if (updateRequired) false else library.muse.open()
|
||||
if (!ok) {
|
||||
library.muse.close()
|
||||
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress)
|
||||
bootstrapError = result.exceptionOrNull()
|
||||
ok = result.isSuccess && library.muse.open()
|
||||
if (!ok) {
|
||||
// A failed online update must not make an existing local catalog unusable.
|
||||
usedOldDatabase = library.muse.open()
|
||||
ok = usedOldDatabase
|
||||
// 先尝试打开本地曲库;仅当本地不可用或已过 30 天时效时才联网同步。
|
||||
var ok = library.muse.open()
|
||||
val syncDue = DatabaseBootstrapper.isSyncDue()
|
||||
if (!ok || syncDue) {
|
||||
val reason = if (!ok) "本地曲库不可用" else "已到 ${DatabaseBootstrapper.SYNC_TTL_DAYS} 天同步时效"
|
||||
Log.i(TAG, "触发曲库服务器同步:$reason")
|
||||
val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
|
||||
if (remoteVersion != null) {
|
||||
val localVersion = DatabaseBootstrapper.getLocalDbVersion()
|
||||
if (!ok || remoteVersion != localVersion) {
|
||||
library.muse.close()
|
||||
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress)
|
||||
bootstrapError = result.exceptionOrNull()
|
||||
ok = result.isSuccess && library.muse.open()
|
||||
if (!ok) {
|
||||
// A failed online update must not make an existing local catalog unusable.
|
||||
usedOldDatabase = library.muse.open()
|
||||
ok = usedOldDatabase
|
||||
}
|
||||
} else {
|
||||
// 版本一致:本次已完成有效校验,重置时效起点。
|
||||
DatabaseBootstrapper.markSynced()
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "曲库同步到期,但获取远端版本失败,本次继续使用本地曲库")
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "曲库未到期(${DatabaseBootstrapper.syncStatusText()}),跳过服务器同步")
|
||||
}
|
||||
|
||||
val count = if (ok) library.muse.songCount() else 0
|
||||
@@ -1912,6 +1929,8 @@ class MainActivity : AppCompatActivity() {
|
||||
// 关于
|
||||
addSettingsTitle(content, "关于")
|
||||
addSettingsItem(content, "应用名称", "麦动", null)
|
||||
addSettingsItem(content, "框架来源", "杨家三郎/麦动KTV", null)
|
||||
addSettingsItem(content, "内核", "由 DSH 重构数据库源", null)
|
||||
addSettingsItem(content, "版本号", BuildConfig.VERSION_NAME, null)
|
||||
addSettingsItem(
|
||||
content,
|
||||
@@ -3488,6 +3507,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}, "ktv-heartbeat").start()
|
||||
}
|
||||
|
||||
/** 远程日志: 把设备 IP 与设备标识同步给日志客户端(GlogCenter 的 serverip/user/traceid)。 */
|
||||
private fun refreshLogDeviceContext() {
|
||||
MiliLog.updateDeviceContext(ip = localIp(), deviceId = LEGACY_DEVICE_ID)
|
||||
}
|
||||
|
||||
/** 远程日志: App 启动时上报设备信息与网络信息(异步, 尽力而为)。 */
|
||||
private fun reportDeviceBootInfo() {
|
||||
Thread({
|
||||
@@ -8232,7 +8256,7 @@ class MainActivity : AppCompatActivity() {
|
||||
SettingsEntry(
|
||||
R.drawable.ott_ic_data_upgrade,
|
||||
"应用更新",
|
||||
"当前版本 ${BuildConfig.VERSION_NAME},更新来源:Gitee Release",
|
||||
"当前版本 ${BuildConfig.VERSION_NAME},更新来源:Gitea Release",
|
||||
"检查更新",
|
||||
) { checkAppUpdate(true) },
|
||||
SettingsEntry(R.drawable.ott_ic_about_us, "关于", actionText = "查看") { showAboutDialog() },
|
||||
@@ -8274,7 +8298,8 @@ class MainActivity : AppCompatActivity() {
|
||||
"统计中…",
|
||||
"更新",
|
||||
) {
|
||||
io.execute { library.muse.close(); library.muse.open(); main.post { showSettingsSection(1, false) } }
|
||||
// 手动强制检查更新(绕过 30 天时效; 仅当服务器版本更新时才下载)
|
||||
forceDatabaseSync()
|
||||
},
|
||||
SettingsEntry(R.drawable.ott_ic_data_setting, "U盘加歌", "扫描U盘内按规定命名的歌曲文件,并添加到曲库中", "立即加歌") { toast("未检测到U盘") },
|
||||
SettingsEntry(R.drawable.ott_ic_setting_storage_space, "预留存储空间", "当前 ${String.format(Locale.ROOT, "%.1f", reserveStorageGb)} GB") { showReserveStorageDialog() },
|
||||
@@ -8315,9 +8340,55 @@ class MainActivity : AppCompatActivity() {
|
||||
io.execute {
|
||||
val count = runCatching { library.muse.songCount() }.getOrDefault(0)
|
||||
val downloaded = runCatching { countDownloadedFiles() }.getOrDefault(0)
|
||||
// 同步时效(30 天): 未到期不联网, 到期后才触发服务器同步。
|
||||
val syncText = DatabaseBootstrapper.syncStatusText()
|
||||
main.post {
|
||||
if (activeSettingsAdapter === adapter) {
|
||||
adapter.updateSubtitle("曲库", "数据库 $count 首 本地已下载 $downloaded 首")
|
||||
adapter.updateSubtitle(
|
||||
"曲库",
|
||||
"数据库 $count 首 本地已下载 $downloaded 首 · ${DatabaseBootstrapper.SYNC_TTL_DAYS} 天同步: $syncText",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动强制检查曲库更新(绕过 30 天时效)。
|
||||
* 仅在服务器版本更新(或本地库不可用)时才下载, 避免无谓的大文件传输。
|
||||
*/
|
||||
private fun forceDatabaseSync() {
|
||||
showDatabaseLoading(true, "正在检查曲库更新...", null)
|
||||
io.execute {
|
||||
val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
|
||||
if (remoteVersion == null) {
|
||||
main.post {
|
||||
showDatabaseLoading(false, "", null)
|
||||
toast("检查失败:无法连接曲库服务器")
|
||||
showSettingsSection(1, false)
|
||||
}
|
||||
return@execute
|
||||
}
|
||||
val localVersion = DatabaseBootstrapper.getLocalDbVersion()
|
||||
if (library.muse.isAvailable() && remoteVersion == localVersion) {
|
||||
DatabaseBootstrapper.markSynced()
|
||||
main.post {
|
||||
showDatabaseLoading(false, "", null)
|
||||
toast("曲库已是最新版本")
|
||||
showSettingsSection(1, false)
|
||||
}
|
||||
return@execute
|
||||
}
|
||||
library.muse.close()
|
||||
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress)
|
||||
val ok = result.isSuccess && library.muse.open()
|
||||
main.post {
|
||||
if (ok) {
|
||||
showDatabaseLoading(false, "", null)
|
||||
toast("曲库同步完成")
|
||||
showSettingsSection(1, false)
|
||||
} else {
|
||||
showDatabaseLoadingFailure("曲库同步失败:${result.exceptionOrNull()?.message.orEmpty()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8855,7 +8926,8 @@ class MainActivity : AppCompatActivity() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("关于麦动")
|
||||
.setMessage(
|
||||
"power by 抖音@杨家三郎\n" +
|
||||
"框架来源:杨家三郎/麦动KTV\n" +
|
||||
"由 DSH 重构数据库源\n" +
|
||||
"此软件免费开源,请勿付费获取\n" +
|
||||
"版本:" + BuildConfig.VERSION_NAME
|
||||
)
|
||||
|
||||
@@ -3,199 +3,189 @@ package com.local.ktv
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.BufferedOutputStream
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Calendar
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
|
||||
/**
|
||||
* 远程日志客户端 —— 复刻为内网/私有部署的 syslog 服务器(zb.kasugano.cn:514)。
|
||||
* 远程日志客户端 —— GlogCenter(gotoeasy/glogcenter)HTTP 接口。
|
||||
*
|
||||
* - 传输: TCP + TLS(信任打包的 raw/mili_log.crt 根证书, 服务器为 Let's Encrypt
|
||||
* 签发链 ISRG Root X1, 故该根即可完成链校验; 连接目标域名与证书 SAN 不同,
|
||||
* 属私有部署惯例, 因此只做链校验、不做主机名校验)。
|
||||
* - 格式: BSD syslog RFC 3164 —— <PRI>MMM dd HH:mm:ss HOSTNAME TAG: message
|
||||
* - 策略: 单后台线程 + 有界队列; 断线自动重连; 尽力而为, 失败不阻塞业务。
|
||||
* 接口:`POST http://log.kasugano.cn/glc/v1/log/add`,`Content-Type: application/json`,
|
||||
* 单条 JSON 对象(批量用 `/glc/v1/log/addBatch`,此处按单条上报)。
|
||||
*
|
||||
* 日志事件:
|
||||
* 1) App 启动时上报设备/网络信息;
|
||||
* 2) 点歌解析歌曲源(w.w345.my / 实际下载域名)时实时上报;
|
||||
* 3) 网络错误/接口 403/下载失败/播放失败实时上报。
|
||||
* 字段(均为字符串,对应 GlogCenter 页面列):
|
||||
* system 系统名(本 App 固定 [SYSTEM])
|
||||
* date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致)
|
||||
* text 日志内容
|
||||
* servername 主机名(设备型号)
|
||||
* serverip 主机 IP(设备局域网 IP)
|
||||
* loglevel 日志级别(INFO / WARN / ERROR)
|
||||
* traceid 追踪码(此处用设备标识)
|
||||
* user 用户(此处用设备标识)
|
||||
*
|
||||
* 策略:单后台线程 + 有界队列;发送失败重试一次,连续失败退避;尽力而为,不阻塞业务。
|
||||
* 日志事件:启动设备/网络信息、点歌命中的源与结果、接口 403/网络错误、下载/播放失败。
|
||||
*/
|
||||
object MiliLog {
|
||||
private const val TAG = "MiliLog"
|
||||
private const val HOST = "zb.kasugano.cn"
|
||||
private const val PORT = 514
|
||||
private const val TAG_NAME = "MaidongKTV"
|
||||
private const val FACILITY = 16 // local0
|
||||
private const val MAX_MSG_BYTES = 900
|
||||
private const val ENDPOINT = "http://log.kasugano.cn/glc/v1/log/add"
|
||||
|
||||
/** GlogCenter 页面上的「系统名」,用于区分应用。 */
|
||||
private const val SYSTEM = "maidongktv"
|
||||
private const val MAX_TEXT_LEN = 900
|
||||
private const val MAX_QUEUE = 300
|
||||
private const val CONNECT_TIMEOUT_MS = 8000
|
||||
private const val SOCKET_TIMEOUT_MS = 10000
|
||||
private const val RETRY_SLEEP_MS = 10_000L
|
||||
|
||||
// RFC 3164 severity
|
||||
private const val SEV_ERROR = 3
|
||||
private const val SEV_WARNING = 4
|
||||
private const val SEV_NOTICE = 5
|
||||
private const val SEV_INFO = 6
|
||||
private const val READ_TIMEOUT_MS = 8000
|
||||
private const val RETRY_SLEEP_MS = 1500L
|
||||
private const val FAILURE_BACKOFF_MS = 10_000L
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
private val running = AtomicBoolean(false)
|
||||
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE)
|
||||
private var sslContext: SSLContext? = null
|
||||
private var hostname = "android"
|
||||
|
||||
@Volatile private var socket: SSLSocket? = null
|
||||
private var senderThread: Thread? = null
|
||||
|
||||
/** 用 app context 初始化(加载打包证书、读取设备标识)。可重复调用。 */
|
||||
@Volatile private var serverName: String = "android"
|
||||
@Volatile private var serverIp: String = ""
|
||||
@Volatile private var traceId: String = ""
|
||||
@Volatile private var user: String = ""
|
||||
|
||||
/** 用 app context 初始化并启动发送线程。可重复调用。 */
|
||||
fun init(context: Context) {
|
||||
if (started.getAndSet(true)) return
|
||||
val appContext = context.applicationContext
|
||||
hostname = sanitize(Build.MODEL).ifEmpty { "android" }
|
||||
sslContext = buildSslContext(appContext)
|
||||
serverName = sanitize(Build.MODEL).ifEmpty { "android" }
|
||||
running.set(true)
|
||||
senderThread = Thread({ sendLoop() }, "mili-syslog").also { it.isDaemon = true; it.start() }
|
||||
senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() }
|
||||
}
|
||||
|
||||
private fun sanitize(s: String): String =
|
||||
s.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else ' ' }
|
||||
.joinToString("").trim().replace(Regex("\\s+"), "-")
|
||||
|
||||
private fun buildSslContext(context: Context): SSLContext? = try {
|
||||
val cf = CertificateFactory.getInstance("X.509")
|
||||
val cert = context.resources.openRawResource(R.raw.mili_log).use {
|
||||
cf.generateCertificate(it) as X509Certificate
|
||||
/** 更新上报用的设备网络/身份字段(IP 变化或重新登录时调用)。 */
|
||||
fun updateDeviceContext(ip: String? = null, deviceId: String? = null) {
|
||||
if (!ip.isNullOrBlank()) serverIp = ip
|
||||
if (!deviceId.isNullOrBlank()) {
|
||||
user = deviceId
|
||||
traceId = deviceId
|
||||
}
|
||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
keyStore.load(null)
|
||||
keyStore.setCertificateEntry("mili_log", cert)
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(keyStore)
|
||||
SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "buildSslContext failed", e)
|
||||
null
|
||||
}
|
||||
|
||||
// ─── 对外上报接口 ────────────────────────────────
|
||||
fun info(msg: String) = enqueue("INFO", msg)
|
||||
fun notice(msg: String) = enqueue("INFO", msg)
|
||||
fun warn(msg: String) = enqueue("WARN", msg)
|
||||
fun error(msg: String) = enqueue("ERROR", msg)
|
||||
|
||||
fun info(msg: String) = enqueue(SEV_INFO, msg)
|
||||
fun notice(msg: String) = enqueue(SEV_NOTICE, msg)
|
||||
fun warn(msg: String) = enqueue(SEV_WARNING, msg)
|
||||
fun error(msg: String) = enqueue(SEV_ERROR, msg)
|
||||
|
||||
private fun enqueue(severity: Int, msg: String) {
|
||||
if (!started.get() || sslContext == null) return
|
||||
val line = format(severity, msg)
|
||||
if (line == null) return
|
||||
// 队列满时丢最旧, 保证新日志能进队。
|
||||
if (!queue.offer(line)) {
|
||||
private fun enqueue(level: String, msg: String) {
|
||||
if (!started.get()) return
|
||||
val body = buildBody(level, msg) ?: return
|
||||
// 队列满时丢最旧,保证新日志能进队。
|
||||
if (!queue.offer(body)) {
|
||||
queue.poll()
|
||||
queue.offer(line)
|
||||
queue.offer(body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun format(severity: Int, msg: String): String? {
|
||||
val body = msg.replace(Regex("[\\r\\n]+"), " ").trim()
|
||||
if (body.isEmpty()) return null
|
||||
val truncated = if (body.toByteArray(Charsets.UTF_8).size > MAX_MSG_BYTES) {
|
||||
body.take(MAX_MSG_BYTES)
|
||||
} else body
|
||||
val now = Calendar.getInstance()
|
||||
val months = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val day = String.format(Locale.US, "%2d", now.get(Calendar.DAY_OF_MONTH))
|
||||
val time = String.format(Locale.US, "%02d:%02d:%02d",
|
||||
now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND))
|
||||
val pri = FACILITY * 8 + severity
|
||||
// RFC3164: <PRI>TIMESTAMP HOSTNAME TAG: MSG (TAG 不含空格)
|
||||
return "<$pri>${months[now.get(Calendar.MONTH)]} $day $time $hostname $TAG_NAME: $truncated"
|
||||
private fun buildBody(level: String, msg: String): String? {
|
||||
val text = msg.replace(Regex("[\\r\\n]+"), " ").trim()
|
||||
if (text.isEmpty()) return null
|
||||
val truncated = if (text.toByteArray(Charsets.UTF_8).size > MAX_TEXT_LEN) {
|
||||
text.take(MAX_TEXT_LEN)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
return runCatching {
|
||||
JSONObject().apply {
|
||||
put("system", SYSTEM)
|
||||
put("date", timestamp())
|
||||
put("text", truncated)
|
||||
put("servername", serverName)
|
||||
put("serverip", serverIp)
|
||||
put("loglevel", level)
|
||||
put("traceid", traceId)
|
||||
put("user", user)
|
||||
}.toString()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** GlogCenter 检索要求的时间格式:yyyy-MM-dd HH:mm:ss.SSS */
|
||||
private fun timestamp(): String =
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.ROOT).format(Date())
|
||||
|
||||
private fun sanitize(value: String): String =
|
||||
value.map { if (it.isLetterOrDigit() || it == '-' || it == '_' || it == ' ') it else ' ' }
|
||||
.joinToString("").trim()
|
||||
|
||||
// ─── 发送线程 ────────────────────────────────────
|
||||
|
||||
private fun sendLoop() {
|
||||
var consecutiveFailures = 0
|
||||
while (running.get()) {
|
||||
val line: String = try {
|
||||
val body: String = try {
|
||||
queue.take()
|
||||
} catch (e: InterruptedException) {
|
||||
break
|
||||
}
|
||||
var sent = false
|
||||
var attempt = 0
|
||||
while (!sent && attempt < 2) {
|
||||
attempt++
|
||||
try {
|
||||
val s = connectOrReuse()
|
||||
val out = BufferedOutputStream(s.getOutputStream())
|
||||
out.write(line.toByteArray(Charsets.UTF_8))
|
||||
out.write('\n'.code)
|
||||
out.flush()
|
||||
sent = true
|
||||
consecutiveFailures = 0
|
||||
// 同一连接顺手把队列里已积压的消息发完。
|
||||
var next: String? = queue.poll()
|
||||
while (next != null) {
|
||||
out.write(next.toByteArray(Charsets.UTF_8))
|
||||
out.write('\n'.code)
|
||||
out.flush()
|
||||
next = queue.poll()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
closeSocket()
|
||||
if (attempt == 1) {
|
||||
Log.w(TAG, "syslog send failed (${e.message}), retrying...")
|
||||
try { Thread.sleep(1500) } catch (ie: InterruptedException) { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
var sent = post(body)
|
||||
if (!sent) {
|
||||
sleepQuietly(RETRY_SLEEP_MS)
|
||||
sent = post(body)
|
||||
}
|
||||
if (sent) {
|
||||
consecutiveFailures = 0
|
||||
// 顺手把已积压的日志发完。
|
||||
var next: String? = queue.poll()
|
||||
while (next != null) {
|
||||
post(next)
|
||||
next = queue.poll()
|
||||
}
|
||||
} else {
|
||||
consecutiveFailures++
|
||||
Log.w(TAG, "syslog send dropped: $line")
|
||||
Log.w(TAG, "glog send dropped (failures=$consecutiveFailures): ${body.take(120)}")
|
||||
if (consecutiveFailures >= 3) {
|
||||
consecutiveFailures = 0
|
||||
try { Thread.sleep(RETRY_SLEEP_MS) } catch (ie: InterruptedException) { break }
|
||||
sleepQuietly(FAILURE_BACKOFF_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
closeSocket()
|
||||
}
|
||||
|
||||
private fun connectOrReuse(): SSLSocket {
|
||||
val existing = socket
|
||||
if (existing != null && existing.isConnected && !existing.isClosed && !existing.isOutputShutdown) {
|
||||
return existing
|
||||
private fun post(body: String): Boolean = try {
|
||||
val connection = (URL(ENDPOINT).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
|
||||
}
|
||||
val context = sslContext ?: throw IllegalStateException("SSL context not ready")
|
||||
val raw = context.socketFactory.createSocket() as SSLSocket
|
||||
raw.connect(java.net.InetSocketAddress(HOST, PORT), CONNECT_TIMEOUT_MS)
|
||||
raw.soTimeout = SOCKET_TIMEOUT_MS
|
||||
raw.startHandshake()
|
||||
socket = raw
|
||||
Log.i(TAG, "syslog connected to $HOST:$PORT")
|
||||
return raw
|
||||
try {
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
val code = connection.responseCode
|
||||
// 读取并丢弃响应体,确保请求完成(也便于排查)。
|
||||
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||
runCatching { stream?.use { it.readBytes() } }
|
||||
code in 200..299
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "glog send error: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
private fun closeSocket() {
|
||||
try { socket?.close() } catch (ignored: Exception) {}
|
||||
socket = null
|
||||
private fun sleepQuietly(ms: Long) {
|
||||
try {
|
||||
Thread.sleep(ms)
|
||||
} catch (ignored: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
/** 供测试/关闭时调用。 */
|
||||
/** 停止发送(退出时调用)。 */
|
||||
fun shutdown() {
|
||||
running.set(false)
|
||||
senderThread?.interrupt()
|
||||
senderThread = null
|
||||
closeSocket()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import javax.crypto.Cipher
|
||||
/**
|
||||
* 官方曲库同步:login → cookie → sqlite/sync → 下载 gz → 解压安装。
|
||||
*
|
||||
* 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的 Gitee 分片方案。
|
||||
* 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的分片分发方案。
|
||||
* 授权门控发生在服务端(未授权设备 sync 返回空 files),本地只负责完整复刻请求协议。
|
||||
*/
|
||||
object MuseDbSync {
|
||||
|
||||
@@ -10,8 +10,8 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
/**
|
||||
* 歌曲 API 客户端 —— JS 热更新桥接
|
||||
* =================================
|
||||
* - 通过 KtvJsBridge 调用 Gitee 上的 JS 脚本
|
||||
* - API 变更时只需更新 Gitee JS 文件,App 无需发版
|
||||
* - 通过 KtvJsBridge 调用 Gitea 上的 JS 脚本
|
||||
* - API 变更时只需更新 Gitea JS 文件,App 无需发版
|
||||
* - 首次播放时实时获取 URL,不存在过期问题
|
||||
*/
|
||||
object SongApiClient {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----END CERTIFICATE-----
|
||||
Reference in New Issue
Block a user