合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0

版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定

合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/   自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
  已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成

同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)

文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明

验证:assembleRelease + testReleaseUnitTest 全过(39 例)
This commit is contained in:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
versionCode 42
versionName "1.5.10"
versionCode 43
versionName "1.6.0"
ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
+30 -9
View File
@@ -12,7 +12,7 @@
(function(global) {
'use strict';
// KTV_BRIDGE_API: 12 (Android 缓存校验标记,请勿删除)
// KTV_BRIDGE_API: 13 (Android 缓存校验标记,请勿删除)
// ─── 配置 ──────────────────────────────────────────
const CONFIG = {
@@ -125,28 +125,49 @@
return JSON.parse(body.slice(start));
}
// 把 URL 拆成 host / path(**剥离 query 与 fragment**)。
// 所有"看路径/看域名"的判定都基于它:真源地址带 `?token=…&t=…`(2026-10-11 抓包),
// 拿整串判定就会出现"带 query 匹配不到文件名"(漏放)或"query 里的随机串被当广告特征"(误杀)。
function splitUrl(url) {
var s = String(url || '');
var head = s.split('#')[0].split('?')[0];
var m = head.match(/^[a-zA-Z][a-zA-Z0-9+.\-]*:\/\/([^\/]*)(\/.*)?$/);
if (!m) return { host: '', path: head };
var host = m[1].split('@').pop();
if (host.charAt(0) === '[') {
host = host.indexOf(']') > 0 ? host.slice(0, host.indexOf(']')) : host;
} else {
host = host.replace(/:\d+$/, '');
}
return { host: host.toLowerCase(), path: m[2] || '' };
}
// 匹配 demo/广告/测试资源 URL 的多种模式:
// 1) wb66 / demo 关键字 (服务端注入广告示例: https://wb66.cn/f/d/mJMSQ/719.ts)
// 2) IPv4 地址 (如 http://154.222.26.244:656/722.ts)
// 3) Cloudflare R2 demo bucket (pub-*.r2.dev/ss/ 通用测试文件)
// 4) 任务4: ts 文件名编号与请求的 musicno 不一致 → 视为广告/错误资源
// (例: 请求 7018878 却返回 /719.ts 或 /689.ts)
// ⚠️ 判定一律在 **host + path** 上做(见 splitUrl),**不搜 query**。
function isDemoUrl(url, musicno) {
var s = String(url || '');
if (/(?:wb66|demo)/i.test(s)) return true;
var u = splitUrl(s);
var hostPath = u.host + u.path;
if (/(?:wb66|demo)/i.test(hostPath)) return true;
// 广告视频专用域名(含子域)。2026-09-26 事故:新设备(WebView 正常 → 走本 JS 取址)
// 点歌拿到了 gz.ac16.vip 的广告视频,而它通过了下述所有旧规则 → 被当成歌曲下载播放。
if (/^https?:\/\/(?:[^\/]+\.)?ac16\.vip(?::\d+)?\//i.test(s)) return true;
if (/\/ad_files\/|my_ad_video/i.test(s)) return true;
if (/^https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?\//i.test(s)) return true;
if (/(?:^|\.)ac16\.vip$/i.test(u.host)) return true;
if (/\/ad_files\/|my_ad_video/i.test(u.path)) return true;
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(u.host)) return true;
// R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源
if (/pub-[a-z0-9]+\.r2\.dev\/ss\//i.test(s)) return true;
if (/^pub-[a-z0-9]+\.r2\.dev$/i.test(u.host) && /\/ss\//i.test(u.path)) return true;
// ts 文件名必须等于 musicno。原版真源 URL 恒为 /<musicno>.ts(已实测两个 CDN 域
// txog.ktvsky.com / download.origjoy.com 的 6 首歌全部如此)。
// txog.ktvsky.com / download.origjoy.com 的 6 首歌全部如此),且**带 query/fragment**。
// ⚠️ 旧规则只比对"纯数字文件名"(/\/(\d+)\.ts/),像 my_ad_video.ts 这种非数字名字
// 会直接漏过 —— 这正是广告漏放的直接原因之一。
// ⚠️ 旧正则收尾写的是 `(?:\?|$)`:遇到 `#fragment` 会**完全匹配不到 → 整条规则被跳过**。
if (musicno) {
var m = s.match(/\/([^\/?]+)\.ts(?:\?|$)/i);
var m = u.path.match(/\/([^\/]+)\.ts(?:\/|$)/i);
if (m && m[1] !== String(musicno)) return true;
}
return false;
@@ -437,7 +458,7 @@
// ─── 导出 ──────────────────────────────────────────
global.KtvApi = KtvApi;
global.KtvApiConfig = CONFIG;
global.KtvBridgeApiVersion = 12;
global.KtvBridgeApiVersion = 13;
if (typeof module !== 'undefined' && module.exports) {
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
@@ -15,15 +15,21 @@ import android.util.Log
object AdVideoFilter {
private const val TAG = "AdVideoFilter"
/** 命中这些片段的地址视为广告/测试资源(匹配小写)。 */
/**
* 命中这些片段的地址视为广告/测试资源(匹配小写)。
*
* ⚠️ 只在 **host + path** 上匹配(见 [isAdVideo]),**不搜 query** —— 因此这里可以安全地
* 收 `demo`:真源地址的 `?token=…&t=…` 是签名/随机串,旧实现拿整串匹配时 `demo` 会误杀
* 真源,故当时只收了 `wb66` 这种高区分度标记。现在与 JS `isDemoUrl`、服务端
* `resolver.is_trusted_url` 三条实现口径一致(都是 host+path 上的 `wb66|demo`)。
*/
private val AD_MARKERS = listOf(
"/ad_files/",
"my_ad_video",
// 源站 2026-10 起"授权窗口失效"时会对每首歌返回同一个测试资源
// (实测 https://wb66.cn/f/d/7b7c9/10188555.ts)。JS 的 isDemoUrl 早就把 wb66 当
// 测试资源,这里对齐,保证两条取址路径判定一致。
// ⚠️ 只收"wb66"这种高区分度标记:`demo` 之类太宽松的词会误伤真源 URL 里的随机 token。
// (实测 https://wb66.cn/f/d/7b7c9/10188555.ts)。
"wb66",
"demo",
)
/**
@@ -47,13 +53,20 @@ object AdVideoFilter {
@JvmField
var replacementUrl: String? = null
/** 判断是否为广告视频地址(路径特征 + 域名)。 */
/**
* 判断是否为广告视频地址(路径特征 + 域名)。
*
* ⚠️ 特征只在 **host + path** 上匹配(**不搜 query**):真源地址带 `?token=…&t=…`,
* query 里是签名/随机串,拿整串搜 `wb66`/`demo` 会把合法地址误杀(见 [isTrustedSongUrl])。
*/
@JvmStatic
fun isAdVideo(url: String?): Boolean {
if (url.isNullOrEmpty()) return false
val lower = url.lowercase()
val host = hostOf(url)
// host 取不到(畸形 URL)时退化为整串匹配:**宁可拦下,也不要放行广告**。
val lower = ((host ?: url) + pathOf(url)).lowercase()
if (AD_MARKERS.any { lower.contains(it) }) return true
val host = hostOf(url) ?: return false
if (host == null) return false
return AD_HOSTS.any { host == it || host.endsWith(".$it") }
}
@@ -63,26 +76,83 @@ object AdVideoFilter {
* 判定为不可信的情况:
* 1. 非 http(s);
* 2. 命中广告特征([isAdVideo]);
* 3. **URL 里的 `.ts` 文件名与点歌编号不一致** —— 真源 URL 的文件名恒等于点歌编号
* 3. **URL 路径里的 `.ts` 文件名与点歌编号不一致** —— 真源 URL 的文件名恒等于点歌编号
* (见 `ktv_api.js` 的说明),而广告文件常叫 `my_ad_video.ts`。
* 旧规则只处理"纯数字且与编号不同"(`/\/(\d+)\.ts/`),非数字文件名直接放行,
* 这正是本次广告漏放的直接原因。
*
* ⚠️ 第 3 条**必须在剥离 query/fragment 后的 path 上做**:真源地址带 `?token=…&t=…`
* (2026-10-11 抓包:`…/480p/7200088.ts?token=…&t=…`)。旧正则写成
* `/([^/?]+)\.ts(?:\?|$)` 只认 `?` 结尾,遇到 `#fragment` 就**完全匹配不到 → 整条文件名
* 规则被跳过(漏放)**;而拿整串找特征又会把 query 里的签名串当广告特征(误杀)。
*/
@JvmStatic
fun isTrustedSongUrl(url: String?, musicno: String?): Boolean {
if (url.isNullOrEmpty()) return false
if (!url.startsWith("http://") && !url.startsWith("https://")) return false
if (isAdVideo(url)) return false
val name = TS_BASENAME.find(url)?.groupValues?.getOrNull(1)
val host = hostOf(url) ?: return false
val path = pathOf(url)
// 测试资源主机(与 JS `isDemoUrl` / 服务端 `resolver.is_trusted_url` 同口径):
// 裸 IP —— App 只接受域名;`pub-*.r2.dev/ss/` —— Cloudflare 公开测试桶。
if (IP_HOST.matches(host)) return false
if (R2_HOST.matches(host) && path.contains("/ss/")) return false
val name = tsBasename(path)
if (name != null && !musicno.isNullOrEmpty() && name != musicno) return false
return true
}
private fun hostOf(url: String): String? =
runCatching { java.net.URI(url).host?.lowercase() }.getOrNull()
/**
* 取 URL 的 **host**(小写,不含端口);`java.net.URI` 解析失败时退化为手工提取。
*
* 退化的意义:query 里带 `|`、空格这类字符会让 `URI()` 抛异常,旧实现(把异常当成
* "无法判定")会把**合法真源地址误判成 demo 并拒绝** —— 这是"取址失败"的一个隐蔽来源。
*/
@JvmStatic
fun hostOf(url: String?): String? {
val s = url ?: return null
runCatching { java.net.URI(s).host?.lowercase() }.getOrNull()?.let { if (it.isNotEmpty()) return it }
val head = s.substringBefore('#').substringBefore('?')
val m = Regex("^[a-zA-Z][a-zA-Z0-9+.\\-]*://([^/]*)").find(head) ?: return null
var host = m.groupValues.getOrNull(1).orEmpty().substringAfterLast('@')
if (host.startsWith("[")) host = host.substringBefore(']') // IPv6 字面量
else host = host.substringBefore(':')
return host.lowercase().ifEmpty { null }
}
/** 取 `.ts` 的**文件名主体**(不含扩展名),供与点歌编号比对。 */
private val TS_BASENAME = Regex("/([^/?]+)\\.ts(?:\\?|$)", RegexOption.IGNORE_CASE)
/**
* 取 URL 的 **path**(**已剥离 query 与 fragment**)。
*
* 所有"看路径"的判定(`.ts` 文件名、`/ad_files/` 等)都必须用它,
* 不能拿整串 —— 否则 query 一变判定就变。
*/
@JvmStatic
fun pathOf(url: String?): String {
val s = url ?: return ""
runCatching { java.net.URI(s).rawPath }.getOrNull()?.let { if (it != null) return it }
val head = s.substringBefore('#').substringBefore('?')
val m = Regex("^[a-zA-Z][a-zA-Z0-9+.\\-]*://[^/]*(/.*)$").find(head)
return m?.groupValues?.getOrNull(1).orEmpty()
}
/**
* 取 **path** 里 `.ts` 的文件名主体(不含扩展名);path 里没有 `.ts` 则返回 null
* (此时不做文件名比对,避免误杀非 ts 的真源)。
*
* 例:`/264/lvbr/480/E/ts/39.6/202309070206/480p/7200088.ts` → `7200088`。
*/
@JvmStatic
fun tsBasename(path: String?): String? =
TS_BASENAME.find(path.orEmpty())?.groupValues?.getOrNull(1)
/** 取 `.ts` 的**文件名主体**(不含扩展名),供与点歌编号比对(入参是 path)。 */
private val TS_BASENAME = Regex("/([^/]+)\\.ts(?:/|$)", RegexOption.IGNORE_CASE)
/** 裸 IP 主机(IPv4 字面量):App 只接受域名形式的媒体地址。 */
private val IP_HOST = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")
/** Cloudflare R2 公开测试桶主机(配合 path 里的 `/ss/` 判定,见 `isTrustedSongUrl`)。 */
private val R2_HOST = Regex("^pub-[a-z0-9]+\\.r2\\.dev$", RegexOption.IGNORE_CASE)
/**
* 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换);
@@ -0,0 +1,513 @@
package com.local.ktv
import java.security.SecureRandom
import java.util.Random
/**
* 设备身份(**伪装成一台安卓盒子**):内置真盒子模板 + 随机 MAC/SN,生成一次后**固定不变**。
*
* ## 为什么需要它
* 官方 App 是三个请求都带设备身份:`POST hbeat.ktvsky.com/whoami`(心跳)、
* `GET <源站>/i.php?…&mac=<mac>_<sn>`(取 token)、
* `GET <源站>/music/do.php?…&device=<mac>_<sn>`(取歌地址)。
* 以前本 App 上报的是**真实机型**(`Build.MODEL` / `Settings.Secure.ANDROID_ID`),
* 服务端上报的是真实 Linux 机器 —— 两侧都一眼不是"麦动盒子"。现在统一成:
* **随机挑一套常见安卓盒子模板 → 随机 mac/sn/ktv_id → 落盘 → 三个请求同源**。
* 服务端对应实现 `selfhost-source/server/ktv/identity.py`(模板与生成规则**一一对应**)。
*
* ## ⚠️ mac 是"构造值",不是网卡真实 MAC
* Android 6 起 `WifiInfo.getMacAddress()` 恒返回 `02:00:00:00:00:00`,Android 10+ 更是
* 完全禁止读取真实 MAC;即便读得到([DeviceMac]),也不该拿真实网卡地址去上报。
* 这里前 3 字节用**真实盒子/芯片厂商的 OUI**(Amlogic/Realtek/ODM…)、后 3 字节随机,
* 既是**单播**地址(第一个八位组最低位为 0,组播位为 1 一眼假),又不会跟别人撞车。
* 真实网卡 MAC / 真实机型只在**本地日志**里保留,便于排查。
*
* ## 为什么不用 `org.json` / `android.*`
* 单测(`app/src/test`)跑在**纯 JVM** 上,Android SDK 的 `org.json`/`Build`/`Log` 全是
* 会抛 `Stub!` 的空壳;因此这里只用 `java.*`,JSON 也手写一个**扁平**的读写(字段全是字符串)。
*
* ## 持久化
* 存进 [KtvStore](它落在 `<存储根>/state.json`,即本项目的 SharedPreferences 等价物)。
* 身份一旦生成就**稳定不变** —— 上游按 device 识别设备,身份一变就会被当成新设备,
* 之前建立的"合法/活跃"状态全没了。想换身份只能显式走 [regenerate](设置页有入口)。
*/
object DeviceIdentity {
/** `_vc`:原版客户端 versionCode(抓包值,与服务端 `ktv/identity.py` 同源口径)。 */
const val VC = 4
/** `_pkn`:伪装成原版包名(可被设置项 [KtvStore.whoamiPkn] 覆盖)。 */
const val DEFAULT_PKN = "com.origjoy.local.ktv"
/** `ktv_id` 兜底值(身份里会随机生成一个 8 位数字)。 */
const val DEFAULT_KTV_ID = "10006789"
/** `d_user`:原版按 dpi 上报(抓包值)。 */
const val DEFAULT_D_USER = "dpi"
/** 身份 JSON 的版本号(字段增删时升它,便于以后做迁移)。 */
const val VERSION = 1
/** `_vn`:与原版取址协议(i.php / do.php)同一口径,见 [Whoami.VN]。 */
const val VN = Whoami.VN
/**
* 一套盒子模板:**字段必须互相自洽**(`_manuf` 与 fingerprint 的品牌段一致、
* `release` 与 `_api` 对得上、OUI 是这家/这类盒子的真实前缀)。
*
* @param ouis 该模板可用的 MAC 前缀(真实盒子厂商 OUI,全部是单播地址)。
*/
data class Template(
val preset: String,
val label: String,
val manuf: String,
val model: String,
val name: String,
val device: String,
val board: String,
val hardware: String,
val release: String,
val buildId: String,
val incremental: String,
val ouis: List<String>,
)
/**
* 内置模板:与 `selfhost-source/server/ktv/identity.py` 的 `TEMPLATES` 一一对应
* (Amlogic S905X / S905X3 / S905X4、Rockchip RK3328、Realtek RTD1295)。
*
* OUI 取自 IEEE 登记数据:`1ca410` = Amlogic, Inc.、`00e04c` = REALTEK SEMICONDUCTOR;
* `001ec0`/`d43a2e` 是 Amlogic 方案盒子常见前缀;`b49dfd`/`acdbda` 是
* Shenzhen SDMC / Geniatech(Rockchip 方案盒子的 ODM,RK 自身几乎没有成品盒子 OUI)。
*/
@JvmField
val TEMPLATES: List<Template> = listOf(
Template(
preset = "aml_s905x", label = "Amlogic S905X(A95X 类盒子 · Android 9)",
manuf = "Amlogic", model = "A95X", name = "a95x", device = "a95x",
board = "a95x", hardware = "amlogic", release = "9",
buildId = "PPR1.180610.011", incremental = "20190813",
ouis = listOf("001ec0", "d43a2e"),
),
Template(
preset = "aml_s905x3", label = "Amlogic S905X3(X96Max+ 类盒子 · Android 11)",
manuf = "Amlogic", model = "X96MaxPlus", name = "x96maxplus", device = "x96maxplus",
board = "x96maxplus", hardware = "amlogic", release = "11",
buildId = "RQ1A.210105.003", incremental = "20220318",
ouis = listOf("1ca410", "d43a2e"),
),
Template(
preset = "aml_s905x4", label = "Amlogic S905X4(A95X-F4 类盒子 · Android 12)",
manuf = "Amlogic", model = "A95XF4", name = "a95xf4", device = "a95xf4",
board = "a95xf4", hardware = "amlogic", release = "12",
buildId = "SP1A.210812.016", incremental = "20220815",
ouis = listOf("1ca410", "001ec0"),
),
Template(
preset = "rk3328", label = "Rockchip RK3328(H96Max 类盒子 · Android 9)",
manuf = "Rockchip", model = "H96Max", name = "rk3328_box", device = "rk3328_box",
board = "rk3328_box", hardware = "rk30board", release = "9",
buildId = "PPR1.180610.011", incremental = "20190226",
ouis = listOf("b49dfd", "acdbda"),
),
Template(
preset = "rtd1295", label = "Realtek RTD1295(通用 4K 盒子 · Android 9)",
manuf = "Realtek", model = "RTD1295", name = "kylin32", device = "kylin32",
board = "kylin32", hardware = "rtd1295", release = "9",
buildId = "PPR1.180610.011", incremental = "20191210",
ouis = listOf("00e04c"),
),
)
/** 模板名 → 模板。 */
@JvmField
val PRESETS: Map<String, Template> = TEMPLATES.associateBy { it.preset }
/** 所有模板用到的 OUI 合集([validate] 用它判断 mac 是不是盒子厂商的地址)。 */
@JvmField
val OUIS: Set<String> = TEMPLATES.flatMap { it.ouis }.toSet()
/** `d_release` → `_api`(Android 版本号 ↔ API 级别,与服务端 `ANDROID_API` 一致)。 */
private val ANDROID_API = mapOf(
"5.0" to 21, "5.1" to 22, "6.0" to 23, "7.0" to 24, "7.1" to 25, "8.0" to 26,
"8.1" to 27, "9" to 28, "10" to 29, "11" to 30, "12" to 31, "12L" to 32,
"13" to 33, "14" to 34, "15" to 35,
)
/** `_api` → `d_release`(未知取值回退 "12",与本项目既有默认 `whoami_api=31` 一致)。 */
@JvmStatic
fun androidRelease(api: Int): String =
ANDROID_API.entries.firstOrNull { it.value == api }?.key ?: "12"
/** `d_release` → `_api`(未知取值回退 31)。 */
@JvmStatic
fun androidApi(release: String): Int = ANDROID_API[release] ?: 31
/**
* 一份盒子身份(字段名与 `ktv/identity.py` 生成的 JSON 一致,便于两侧对读)。
*
* `mac`/`androidId`/`cid` 三者同源(抓包口径:`androidid == _mac`、`_cid == "A_" + _mac`)。
*/
data class Identity(
val preset: String,
val label: String,
val mac: String,
val sn: String,
val model: String,
val manuf: String,
val name: String,
val device: String,
val board: String,
val hardware: String,
val release: String,
val api: Int,
val buildId: String,
val incremental: String,
val fingerprint: String,
val dUser: String,
val ktvId: String,
val pkn: String,
val vn: String,
val createdAt: Long,
) {
/** 抓包口径:`androidid` 与 `_mac` 同值。 */
val androidId: String get() = mac
/** 抓包口径:`_cid` = `A_` + mac。 */
val cid: String get() = "A_$mac"
/** 组装 whoami 心跳要用的设备事实(三个请求的 mac/sn 由此**同源**)。 */
@JvmOverloads
fun toFacts(pkn: String = "", ktvId: String = ""): Whoami.DeviceFacts = Whoami.DeviceFacts(
model = model,
manufacturer = manuf,
product = name,
device = device,
board = board,
hardware = hardware,
fingerprint = fingerprint,
incremental = incremental,
release = release,
sdkInt = api,
androidId = mac,
mac = mac,
pkn = pkn.ifBlank { this.pkn },
ktvId = ktvId.ifBlank { this.ktvId },
)
/** 扁平 JSON(字段全是字符串;`org.json` 在单测里是空壳,见类头注释)。 */
fun toJson(): String {
val fields = linkedMapOf(
"version" to DeviceIdentity.VERSION.toString(),
"preset" to preset,
"label" to label,
"created_at" to createdAt.toString(),
"mac" to mac,
"sn" to sn,
"_m" to model,
"_manuf" to manuf,
"d_name" to name,
"d_device" to device,
"d_board" to board,
"d_hardware" to hardware,
"d_release" to release,
"_api" to api.toString(),
"d_build_id" to buildId,
"d_incremental" to incremental,
"d_fingerprint" to fingerprint,
"d_user" to dUser,
"ktv_id" to ktvId,
"_pkn" to pkn,
"_vn" to vn,
"_vc" to DeviceIdentity.VC.toString(),
)
return fields.entries.joinToString(",", "{", "}") { (k, v) ->
"\"${jsonEscape(k)}\":\"${jsonEscape(v)}\""
}
}
companion object {
/**
* 从 [toJson] 写出的扁平 JSON 还原;mac/sn 缺失或格式不对返回 null
* (调用方据此重新生成,绝不拿半截身份去上报)。
*/
@JvmStatic
fun fromJson(text: String?): Identity? {
val map = parseFlatJson(text ?: return null)
val mac = map["mac"].orEmpty()
val sn = map["sn"].orEmpty()
if (mac.isEmpty() || sn.isEmpty()) return null
val release = map["d_release"].orEmpty().ifBlank { "12" }
return Identity(
preset = map["preset"].orEmpty(),
label = map["label"].orEmpty(),
mac = mac,
sn = sn,
model = map["_m"].orEmpty(),
manuf = map["_manuf"].orEmpty(),
name = map["d_name"].orEmpty(),
device = map["d_device"].orEmpty(),
board = map["d_board"].orEmpty(),
hardware = map["d_hardware"].orEmpty(),
release = release,
api = map["_api"]?.toIntOrNull() ?: DeviceIdentity.androidApi(release),
buildId = map["d_build_id"].orEmpty(),
incremental = map["d_incremental"].orEmpty(),
fingerprint = map["d_fingerprint"].orEmpty(),
dUser = map["d_user"].orEmpty().ifBlank { DeviceIdentity.DEFAULT_D_USER },
ktvId = map["ktv_id"].orEmpty().ifBlank { DeviceIdentity.DEFAULT_KTV_ID },
pkn = map["_pkn"].orEmpty().ifBlank { DeviceIdentity.DEFAULT_PKN },
vn = map["_vn"].orEmpty().ifBlank { DeviceIdentity.VN },
createdAt = map["created_at"]?.toLongOrNull() ?: 0L,
)
}
}
}
// ── 进程内缓存([install] 装上后全进程共用同一份)──
@Volatile private var cached: Identity? = null
@Volatile private var sink: ((String) -> Unit)? = null
/**
* App 启动时调用(**在任何上报之前**):优先复用 [KtvStore] 里已保存的身份,
* 没有(或已损坏)才生成一份并立刻落盘。
*
* ⚠️ 只做本地小文件读写,**不涉及任何网络**;whoami 心跳仍在后台线程里发。
*/
@JvmStatic
fun install(store: KtvStore): Identity {
val saved = runCatching { Identity.fromJson(store.deviceIdentityJson) }.getOrNull()
val ident = if (saved != null && validate(saved).isEmpty()) saved else generate()
sink = { json ->
runCatching {
store.deviceIdentityJson = json
store.save()
}
}
synchronized(this) { cached = ident }
if (saved == null || saved != ident) sink?.invoke(ident.toJson())
return ident
}
/** 已装载的身份(未装 [install] 时为 null)。 */
@JvmStatic
fun installed(): Identity? = cached
/**
* 当前生效的身份;**没装过 [install] 时**(理论上不该发生)就现场生成一份并缓存,
* 保证上报值永远非空、且同一次进程运行内稳定。
*/
@JvmStatic
fun effective(): Identity {
cached?.let { return it }
synchronized(this) {
cached?.let { return it }
val ident = generate()
cached = ident
return ident
}
}
/** 重新生成一份身份并落盘(设置页的「重新生成」入口)。 */
@JvmStatic
@JvmOverloads
fun regenerate(preset: String = ""): Identity {
val ident = generate(preset)
synchronized(this) { cached = ident }
sink?.invoke(ident.toJson())
return ident
}
/**
* 生成一份新的盒子身份。
*
* @param preset 空 = 随机模板;未知模板名回退随机。
* @param rnd 仅单测用(固定种子可复现)。
*/
@JvmStatic
@JvmOverloads
fun generate(preset: String = "", rnd: Random = SecureRandom()): Identity {
val tpl = PRESETS[preset] ?: TEMPLATES[rnd.nextInt(TEMPLATES.size)]
val mac = newMac(tpl.ouis[rnd.nextInt(tpl.ouis.size)], rnd)
val api = androidApi(tpl.release)
return Identity(
preset = tpl.preset,
label = tpl.label,
mac = mac,
sn = randomHex(12, rnd),
model = tpl.model,
manuf = tpl.manuf,
name = tpl.name,
device = tpl.device,
board = tpl.board,
hardware = tpl.hardware,
release = tpl.release,
api = api,
buildId = tpl.buildId,
incremental = tpl.incremental,
// ⚠️ 用模板里写好的**真机指纹形态**:品牌/产品/设备:版本/构建ID/增量:user/release-keys
fingerprint = "${tpl.manuf}/${tpl.name}/${tpl.device}:${tpl.release}/" +
"${tpl.buildId}/${tpl.incremental}:user/release-keys",
dUser = DEFAULT_D_USER,
ktvId = (rnd.nextInt(90_000_000) + 10_000_000).toString(),
pkn = DEFAULT_PKN,
vn = VN,
createdAt = System.currentTimeMillis() / 1000,
)
}
/**
* 校验身份是否自洽,返回问题列表(**空列表 = 合法**)。
* 与服务端 `identity.validate()` 同一套规则,两侧不会漂移。
*/
@JvmStatic
fun validate(identity: Identity?): List<String> {
if (identity == null) return listOf("身份为空")
val problems = ArrayList<String>()
val mac = identity.mac
if (!Regex("^[0-9a-f]{12}$").matches(mac)) {
problems.add("mac 必须是 12 位小写十六进制")
} else {
if ((mac.substring(0, 2).toInt(16) and 0x01) != 0) {
problems.add("mac 是组播地址(第一个八位组最低位必须为 0)")
}
if (mac == "000000000000") problems.add("mac 全 0(无效地址)")
if (!OUIS.contains(mac.substring(0, 6))) {
problems.add("mac 的 OUI ${mac.substring(0, 6)} 不在内置盒子厂商 OUI 表内")
}
}
if (!Regex("^[0-9a-f]{12}$").matches(identity.sn)) {
problems.add("sn 必须是 12 位小写十六进制")
}
if (identity.androidId != mac) problems.add("androidid 必须等于 mac(抓包口径)")
if (identity.cid != "A_$mac") problems.add("_cid 必须等于 \"A_\" + mac")
listOf("preset", "model", "manuf", "name", "device", "board", "hardware",
"release", "incremental", "dUser", "pkn", "vn", "ktvId").forEach { key ->
val value = when (key) {
"preset" -> identity.preset; "model" -> identity.model
"manuf" -> identity.manuf; "name" -> identity.name
"device" -> identity.device; "board" -> identity.board
"hardware" -> identity.hardware; "release" -> identity.release
"incremental" -> identity.incremental; "dUser" -> identity.dUser
"pkn" -> identity.pkn; "vn" -> identity.vn; else -> identity.ktvId
}
if (value.isBlank()) problems.add("$key 不能为空")
}
if (identity.api !in 21..35) {
problems.add("_api ${identity.api} 不是合法的 Android API 级别")
} else if (androidRelease(identity.api) != identity.release) {
problems.add("_api=${identity.api} 与 d_release=${identity.release} 不自洽" +
"(应为 ${androidRelease(identity.api)})")
}
val fp = identity.fingerprint
if (!fp.startsWith(identity.manuf + "/", ignoreCase = true)) {
problems.add("d_fingerprint 必须以 `_manuf/` 开头(brand 与厂商一致)")
}
if (!fp.contains(":${identity.release}/")) {
problems.add("d_fingerprint 里的版本段必须等于 d_release")
}
if (!fp.endsWith(":user/release-keys")) {
problems.add("d_fingerprint 必须以 `:user/release-keys` 结尾(真机固件格式)")
}
if (fp.count { it == '/' } < 4 || fp.count { it == ':' } < 2) {
problems.add("d_fingerprint 分段数量不像真机指纹")
}
return problems
}
// ── 生成工具 ───────────────────────────────────────────
/** `OUI + 3 字节随机` 的 12 位小写 hex;前缀来自真实盒子厂商,因此**天然是单播**。 */
@JvmStatic
fun newMac(oui: String, rnd: Random = SecureRandom()): String =
oui.lowercase() + randomHex(6, rnd)
/** 12 位随机小写 hex(`sn`)。 */
@JvmStatic
@JvmOverloads
fun randomHex(length: Int, rnd: Random = SecureRandom()): String {
val hex = "0123456789abcdef"
val sb = StringBuilder(length)
repeat(length) { sb.append(hex[rnd.nextInt(16)]) }
return sb.toString()
}
// ── 扁平 JSON(手写:单测里 org.json 是空壳,见类头注释)──
private fun jsonEscape(text: String): String {
val sb = StringBuilder(text.length + 8)
for (ch in text) {
when (ch) {
'"' -> sb.append("\\\"")
'\\' -> sb.append("\\\\")
'\n' -> sb.append("\\n")
'\r' -> sb.append("\\r")
'\t' -> sb.append("\\t")
else -> if (ch < ' ') sb.append(String.format("\\u%04x", ch.code)) else sb.append(ch)
}
}
return sb.toString()
}
private fun jsonUnescape(text: String): String {
if (!text.contains('\\')) return text
val sb = StringBuilder(text.length)
var i = 0
while (i < text.length) {
val ch = text[i]
if (ch != '\\' || i + 1 >= text.length) {
sb.append(ch); i++; continue
}
when (val next = text[i + 1]) {
'"' -> { sb.append('"'); i += 2 }
'\\' -> { sb.append('\\'); i += 2 }
'/' -> { sb.append('/'); i += 2 }
'n' -> { sb.append('\n'); i += 2 }
'r' -> { sb.append('\r'); i += 2 }
't' -> { sb.append('\t'); i += 2 }
'u' -> {
val hex = text.substring(i + 2, minOf(text.length, i + 6))
val code = hex.toIntOrNull(16)
if (code != null) { sb.append(code.toChar()); i += 6 } else { sb.append(next); i += 2 }
}
else -> { sb.append(next); i += 2 }
}
}
return sb.toString()
}
/** 扫描扁平 JSON 对象里的 `"key":"value"` 对(只认字符串值,够本文件用)。 */
private fun parseFlatJson(text: String): Map<String, String> {
val out = LinkedHashMap<String, String>()
var i = 0
while (i < text.length) {
val keyStart = text.indexOf('"', i)
if (keyStart < 0) break
val keyEnd = scanStringEnd(text, keyStart + 1) ?: break
val key = jsonUnescape(text.substring(keyStart + 1, keyEnd))
i = keyEnd + 1
val colon = text.indexOf(':', i)
if (colon < 0) break
var j = colon + 1
while (j < text.length && text[j].isWhitespace()) j++
if (j >= text.length || text[j] != '"') { i = j; continue }
val valueEnd = scanStringEnd(text, j + 1) ?: break
out[key] = jsonUnescape(text.substring(j + 1, valueEnd))
i = valueEnd + 1
}
return out
}
/** 从 `from` 起找字符串的结束引号(跳过 `\"`)。 */
private fun scanStringEnd(text: String, from: Int): Int? {
var i = from
while (i < text.length) {
when (text[i]) {
'\\' -> i += 2
'"' -> return i
else -> i++
}
}
return null
}
}
+13 -7
View File
@@ -5,11 +5,17 @@ import java.net.NetworkInterface
import java.util.Locale
/**
* 设备 MAC 解析 —— 取「当前实际在用的网卡」的硬件地址。
* 设备 MAC 解析 —— 取「当前实际在用的网卡」的硬件地址(**仅供本地日志/排错**)。
*
* 背景:歌曲源(w.w345.my / e.ac19.cn 等)与设备心跳(whoami)按 `device`(mac + sn)
* 识别设备。早期实现把 mac 写死为抓包设备的固定值,导致所有设备共用同一身份。
* 这里改为读取本机当前启用网卡的 MAC(12 位小写十六进制、无分隔符)。
* ⚠️ **2026-10-11 起不再用于上报**:上报给上游的 mac 一律取 [DeviceIdentity] 里那份
* "伪装成安卓盒子"的构造值(真实盒子厂商 OUI + 随机后缀,生成一次、落盘后固定不变)。
* 原因:
* 1. Android 6 起 `WifiInfo.getMacAddress()` 恒返回 `02:00:00:00:00:00`,
* Android 10+ 更是完全禁止读取真实 MAC;
* 2. 真实机型/真实网卡在抓包口径里一眼就不是盒子,且每台设备各报各的;
* 3. 上游按 device 识别设备,真实网卡一换(换网线/换路由)身份就漂移。
*
* 这里保留它只为**现场排查**(本地日志里的 `DEVICE-ID ... 真实网卡=…`)。
*
* 取值优先级:
* 1) 启用中、非虚拟、名称形如 `wlan*` / `eth*` / `en*` 的网卡(有线优先于无线);
@@ -17,11 +23,11 @@ import java.util.Locale
* 3) [FALLBACK_MAC](历史抓包值,保证读不到时仍沿用旧身份,不会变成空值)。
*
* 注意:
* - Android 6 起 `WifiInfo.getMacAddress()` 恒返回 `02:00:00:00:00:00`,
* 因此这里不使用 WifiManager,直接读 [NetworkInterface.getHardwareAddress];
* - 直接读 [NetworkInterface.getHardwareAddress](不碰 WifiManager,见上);
* - `NetworkInterface.isUp/isVirtual` 是 API 24 才有的方法,低版本上会抛错,
* 统一用 [safeUp]/[safeVirtual] 包一层,失败时按「可用/非虚拟」处理;
* - 结果按进程缓存,避免每次点歌/心跳都枚举网卡;网络切换时调用 [refresh] 重新解析。
* - 结果按进程缓存,避免每次心跳/点歌都枚举网卡;网络切换时调用 [refresh] 重新解析
* (**注意**:这只影响本地日志,绝不能因此改动上报身份)。
*/
object DeviceMac {
private const val TAG = "DeviceMac"
@@ -47,11 +47,12 @@ class KtvJsBridge(context: Context) {
private const val JS_REQUEST_TIMEOUT_MS = 180_000
/** WebView 初始化看门狗:超过这个时间还没就绪就判定本机 WebView 不可用。 */
private const val BRIDGE_INIT_TIMEOUT_MS = 20_000L
// 歌曲源(w.w345.my 与 e.ac19.cn 同后台)按 device 识别; 随机/变化的 device 会被风控。
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
private val DEVICE_MAC: String get() = DeviceMac.effective()
internal const val DEVICE_SN = "2d7bb4a7d7f7"
// 歌曲源按 device(mac + sn) 识别;随机/变化的 device 会被判"异常行为",因此这两个值
// 来自**生成一次、落盘后固定不变**的设备身份(DeviceIdentity:伪装成一台安卓盒子)。
// ⚠️ 以前 mac 取本机网卡硬件地址、sn 是抓包常量:真实机型/真实网卡一眼就不是盒子,
// 而且 Android 10+ 也拿不到真实 MAC。真实值只留在本地日志里(见 MainActivity)。
private val DEVICE_MAC: String get() = DeviceIdentity.effective().mac
internal val DEVICE_SN: String get() = DeviceIdentity.effective().sn
/**
* 断网重连后重置歌曲源会话。
@@ -358,7 +359,7 @@ class KtvJsBridge(context: Context) {
runCatching {
file.isFile && file.length() in 1_001..500_000 &&
file.readText(Charsets.UTF_8).let {
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 12")
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 13")
}
}.getOrDefault(false)
@@ -384,7 +385,7 @@ class KtvJsBridge(context: Context) {
val script = String(bytes, StandardCharsets.UTF_8)
require(
script.length > 1000 && script.contains("KtvApi") &&
script.contains("KTV_BRIDGE_API: 12"),
script.contains("KTV_BRIDGE_API: 13"),
) { "Invalid or incompatible JS file" }
tempFile.writeText(script, Charsets.UTF_8)
if (!tempFile.renameTo(cacheFile)) {
@@ -53,6 +53,26 @@ class KtvStore {
/** 各频段增益(dB),逗号分隔;不同设备频段数可能不同,加载后会按实际频段数对齐。 */
@JvmField var eqLevels = ""
@JvmField var eqGlobalMix = false
/** whoami 心跳的 `_pkn`(伪装包名):默认原版包名,可在设置里改。 */
@JvmField var whoamiPkn = DEFAULT_WHOAMI_PKN
/**
* 设备身份(**伪装成一台安卓盒子**,见 [DeviceIdentity])的 JSON。
*
* 存这里而不是另开 SharedPreferences:本项目的"设置持久化"就是这份 `state.json`
* (`AppPaths.stateFile`)—— 两者放一起才能保证同一处读写、同一处备份/清理。
* 生成一次后**稳定不变**(上游按 device 识别设备,一变就被当新设备);
* 只有设置页的「重新生成设备身份」会覆盖它。
*/
@JvmField var deviceIdentityJson = ""
/**
* 「App 点歌推送」:把点歌取到的下载地址同步推给自建服务器(`POST <地址>/push_download`,
* 见 [PushDownload] / [PushDownloadClient] / [SongApiClient.getSongDownloadUrl])。
*
* 默认**空 = 关闭该功能**:为空时连请求都不发(现有用户完全无感)。
*/
@JvmField var pushServerUrl = ""
/** 推送鉴权串:必须与服务端的 `push_token` 一致(默认 [DEFAULT_PUSH_TOKEN])。 */
@JvmField var pushToken = DEFAULT_PUSH_TOKEN
@JvmField val orderQueue = mutableListOf<Song>()
@JvmField val sangHistory = mutableListOf<Song>()
@JvmField val scoreHistory = mutableListOf<String>()
@@ -106,6 +126,10 @@ class KtvStore {
eqPreset = root.optString("eqPreset", "自定义")
eqLevels = root.optString("eqLevels", "")
eqGlobalMix = root.optBoolean("eqGlobalMix", false)
whoamiPkn = root.optString("whoamiPkn", DEFAULT_WHOAMI_PKN).ifBlank { DEFAULT_WHOAMI_PKN }
deviceIdentityJson = root.optString("deviceIdentityJson", "")
pushServerUrl = root.optString("pushServerUrl", "")
pushToken = root.optString("pushToken", DEFAULT_PUSH_TOKEN).ifBlank { DEFAULT_PUSH_TOKEN }
readSongs(root.optJSONArray("orderQueue"), orderQueue)
readSongs(root.optJSONArray("sangHistory"), sangHistory)
root.optJSONArray("scoreHistory").copyStringsTo(scoreHistory)
@@ -178,6 +202,10 @@ class KtvStore {
put("eqPreset", eqPreset)
put("eqLevels", eqLevels)
put("eqGlobalMix", eqGlobalMix)
put("whoamiPkn", whoamiPkn)
put("deviceIdentityJson", deviceIdentityJson)
put("pushServerUrl", pushServerUrl)
put("pushToken", pushToken)
put("orderQueue", writeSongs(orderQueue))
put("sangHistory", writeSongs(sangHistory))
put("scoreHistory", JSONArray(scoreHistory.take(100)))
@@ -225,6 +253,12 @@ class KtvStore {
*/
const val DEFAULT_MUSIC_VOLUME = 85
/** whoami 心跳的默认 `_pkn`(原版包名,抓包值;单一来源见 [Whoami.DEFAULT_PKN])。 */
const val DEFAULT_WHOAMI_PKN = Whoami.DEFAULT_PKN
/** 「App 点歌推送」的默认鉴权串(与服务端 `push_token` 默认值同源,见 [PushDownload.DEFAULT_TOKEN])。 */
const val DEFAULT_PUSH_TOKEN = PushDownload.DEFAULT_TOKEN
@JvmStatic
fun stableId(song: Song): String = song.id?.takeIf(String::isNotEmpty)
?: song.path?.takeIf(String::isNotEmpty)
+171 -44
View File
@@ -212,6 +212,14 @@ class MainActivity : AppCompatActivity() {
/** 周期向 hbeat.ktvsky.com 上报设备身份,维持歌曲源对"合法设备"的判定。 */
private var heartbeatTicks = 0
/**
* 下次 whoami 的间隔(秒)。
*
* 优先用**上游响应里的 `next_interval`**(抓包实测 3600,见 [Whoami.parseNextInterval]);
* 这一轮先按上一轮的值排队,回来后刷新 —— 间隔是小时级,晚一轮生效没有影响。
*/
@Volatile private var heartbeatIntervalSec = Whoami.DEFAULT_INTERVAL_SEC
private val ktvHeartbeatRunnable = object : Runnable {
override fun run() {
sendDeviceHeartbeat(boot = false)
@@ -219,7 +227,7 @@ class MainActivity : AppCompatActivity() {
// 没有"状态样本",现场排查空间/曲库/网络随时间的变化时缺参照。
heartbeatTicks++
if (heartbeatTicks % 6 == 0) logHealthSnapshot()
main.postDelayed(this, HEARTBEAT_INTERVAL_MS)
main.postDelayed(this, heartbeatIntervalSec.coerceAtLeast(Whoami.MIN_INTERVAL_SEC) * 1000L)
}
}
@@ -474,6 +482,21 @@ class MainActivity : AppCompatActivity() {
Log.w(TAG, "存储目录创建失败: ${AppPaths.storageFacts(this)}")
}
store.load()
// 设备身份(**伪装成一台安卓盒子**,见 DeviceIdentity):必须在任何上报之前装载好
// (onCreate 稍后就会发第一次 whoami 心跳,JS 取址也用它注入 mac/sn)。
// 纯本地小文件读写,不联网;生成一次后固定不变(变了会被上游当成新设备)。
val deviceIdentity = runCatching { DeviceIdentity.install(store) }.getOrElse { error ->
MiliLog.localWarn("DEVICE-ID 设备身份装载失败(改用进程内临时身份):${error.javaClass.simpleName}")
DeviceIdentity.effective()
}
// 真实机型 / 真实网卡 MAC **只写本地日志**(便于现场排查),不再上报给上游。
MiliLog.local(
"DEVICE-ID 伪装设备 模板=${deviceIdentity.preset} mac=${deviceIdentity.mac} " +
"sn=${deviceIdentity.sn} 机型=${deviceIdentity.model} " +
"Android ${deviceIdentity.release}(_api=${deviceIdentity.api})|" +
"真实机型=${Build.MANUFACTURER} ${Build.MODEL} 真实网卡=${deviceMac()} " +
"Android ${Build.VERSION.RELEASE}",
)
loadStateFromStore()
stateDatabase = KtvStateDatabase(applicationContext)
if (clearDownloadsOnBoot) clearDownloadedFilesOnBoot()
@@ -563,9 +586,9 @@ class MainActivity : AppCompatActivity() {
KtvNetworkMonitor.start(applicationContext) { online, initial ->
onNetworkStateChanged(online, initial)
}
// 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。
// 设备心跳:开机立即上报一次(boot=1),之后按上游 next_interval 保活,维持歌曲源合法设备判定。
sendDeviceHeartbeat(boot = true)
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
main.postDelayed(ktvHeartbeatRunnable, heartbeatIntervalSec * 1000L)
main.post(lyricTicker)
main.postDelayed({ checkAppUpdate(false) }, 2500L)
// 电视/投影等设备: 请求麦克风权限并保持输入占用, 否则麦克风声音不会输出到扬声器。
@@ -3657,52 +3680,102 @@ class MainActivity : AppCompatActivity() {
})
}
/** 复刻原版「时光KTV」的 whoami 心跳,维持设备在歌曲源后台的"合法/活跃"状态。 */
/** 复刻原版「时光KTV」的 whoami 心跳,维持设备在歌曲源后台的"合法/活跃"状态。 */
private fun sendDeviceHeartbeat(boot: Boolean) {
// ⚠️ 上报的是**伪装设备身份**(DeviceIdentity:生成一次、落盘、固定不变),
// 不是真实机型/Build.ANDROID_ID/真实网卡 MAC:
// * 真实机型在抓包口径里一眼不是盒子;
// * Android 10+ 根本拿不到真实 MAC,旧写法还会让每台设备身份漂移;
// * 这一份 mac/sn 与 i.php 的 `mac=<mac>_<sn>`、do.php 的 `device=<mac>_<sn>` 同源。
// 真实机型只在本地日志里保留(见 onCreate 的 DEVICE-ID 行)。
val facts = DeviceIdentity.effective().toFacts(
pkn = runCatching { store.whoamiPkn }.getOrDefault(Whoami.DEFAULT_PKN),
)
val url = Whoami.buildUrl(facts, System.currentTimeMillis(), boot)
val ua = Whoami.userAgent(facts)
// 全程后台 + 8s 超时:主线程绝不碰网络(见 AGENTS「主线程禁止网络 I/O」)。
Thread({
var conn: HttpURLConnection? = null
try {
val now = System.currentTimeMillis()
val sb = StringBuilder("http://hbeat.ktvsky.com/whoami?_src=600007&_src_at=600001")
sb.append("&_m=").append(java.net.URLEncoder.encode(Build.MODEL, "UTF-8"))
sb.append("&_mac=").append(deviceMac())
sb.append("&_vc=4&_vn=4.1.3.03281430")
sb.append("&_pkn=").append(LEGACY_PACKAGE)
sb.append("&_player=0&_boot=").append(if (boot) 1 else 0)
sb.append("&_ts=").append(now)
sb.append("&_api=").append(Build.VERSION.SDK_INT)
sb.append("&ktv_id=").append(LEGACY_KTV_ID)
sb.append("&androidid=").append(deviceMac())
sb.append("&_manuf=").append(java.net.URLEncoder.encode(Build.MANUFACTURER, "UTF-8"))
sb.append("&_cid=A_").append(deviceMac())
sb.append("&d_name=").append(java.net.URLEncoder.encode(Build.PRODUCT, "UTF-8"))
sb.append("&d_device=").append(java.net.URLEncoder.encode(Build.DEVICE, "UTF-8"))
sb.append("&d_board=").append(java.net.URLEncoder.encode(Build.BOARD, "UTF-8"))
sb.append("&d_hardware=").append(java.net.URLEncoder.encode(Build.HARDWARE, "UTF-8"))
sb.append("&d_fingerprint=").append(java.net.URLEncoder.encode(Build.FINGERPRINT, "UTF-8"))
sb.append("&d_incremental=").append(java.net.URLEncoder.encode(Build.VERSION.INCREMENTAL, "UTF-8"))
sb.append("&d_release=").append(java.net.URLEncoder.encode(Build.VERSION.RELEASE, "UTF-8"))
sb.append("&d_utc=").append(now).append("&d_user=dpi")
val conn = (URL(sb.toString()).openConnection() as HttpURLConnection).apply {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = 8000
readTimeout = 8000
connectTimeout = Whoami.TIMEOUT_MS
readTimeout = Whoami.TIMEOUT_MS
setRequestProperty("Content-Type", "text/plain; charset=utf-8")
setRequestProperty("User-Agent", ua)
}
try {
conn.outputStream.use { it.write(ByteArray(0)) }
Log.i(TAG, "whoami heartbeat HTTP ${conn.responseCode}")
} finally {
conn.disconnect()
conn.outputStream.use { it.write(ByteArray(0)) } // 抓包里 body 为空
val code = conn.responseCode
val body = if (code in 200..299) {
runCatching { conn.inputStream.bufferedReader().use { it.readText() } }.getOrNull()
} else {
null
}
} catch (ignored: Exception) {
// 上游用响应里的 next_interval 指定下次上报间隔(抓包实测 3600s)。
heartbeatIntervalSec = Whoami.clampInterval(Whoami.parseNextInterval(body))
Log.i(TAG, "whoami heartbeat HTTP $code next=${heartbeatIntervalSec}s boot=$boot")
} catch (e: Exception) {
// 心跳失败不影响取址/播放:只留本地日志(远程日志保持"每次事件一条结论")。
MiliLog.localWarn("WHOAMI 心跳失败 ${e.javaClass.simpleName}: ${e.message}")
} finally {
runCatching { conn?.disconnect() }
}
}, "ktv-heartbeat").start()
}
/** 当前设备 MAC(12 位小写十六进制): 本机当前在用网卡, 读不到时回退历史抓包值。 */
/**
* 当前**真实**网卡 MAC(12 位小写十六进制,读不到时回退历史抓包值)。
*
* ⚠️ 只用于**本地日志/排错**:上报给上游的 mac 一律是 [DeviceIdentity] 里那份构造值
* (Android 10+ 拿不到真实 MAC,且真实机型一眼不是盒子)。
*/
private fun deviceMac(): String = DeviceMac.effective()
/**
* 设备身份(伪装为安卓盒子):查看当前身份 + 重新生成(可选内置模板)。
*
* 生成一次后固定不变,**只有这里能换** —— 上游按 device 识别设备,身份一变就当新设备。
*/
private fun showDeviceIdentityDialog() {
val ident = DeviceIdentity.effective()
val items = arrayOf<String>("重新生成(随机模板)") +
DeviceIdentity.TEMPLATES.map { "换成 ${it.label}" }
AlertDialog.Builder(this)
.setTitle("设备身份(伪装为安卓盒子)")
.setMessage(
"当前身份(i.php / do.php / whoami 三处同源):\n" +
"模板:${ident.preset}\n" +
"MAC:${ident.mac}\n" +
"SN:${ident.sn}\n" +
"机型:${ident.model}(${ident.manuf})· Android ${ident.release}(_api=${ident.api})\n" +
"指纹:${ident.fingerprint}\n\n" +
"真实机型:${Build.MANUFACTURER} ${Build.MODEL}(只写本地日志)\n\n" +
"⚠️ 重新生成后 mac/sn 会变,上游会把本机当成一台新设备(已有的活跃/额度状态需重新建立)。",
)
.setItems(items) { _, which ->
regenerateDeviceIdentity(if (which == 0) "" else DeviceIdentity.TEMPLATES[which - 1].preset)
}
.setNegativeButton("关闭", null)
.create().showForTv()
}
/** 重新生成设备身份并立即生效(新身份落盘 + 清掉旧 token + 马上补一次心跳)。 */
private fun regenerateDeviceIdentity(preset: String) {
val ident = runCatching { DeviceIdentity.regenerate(preset) }.getOrElse { error ->
toast("重新生成设备身份失败:${error.message}")
return
}
// 旧 token 绑在旧 device 上:清掉歌曲源会话,下一次取址用新身份重新登录。
runCatching { SongApiClient.clearTokenCache() }
runCatching { sendDeviceHeartbeat(boot = false) } // 后台线程里发,不阻塞界面
MiliLog.local(
"DEVICE-ID 已重新生成 模板=${ident.preset} mac=${ident.mac} sn=${ident.sn} " +
"机型=${ident.model}|真实机型=${Build.MANUFACTURER} ${Build.MODEL}",
)
toast("设备身份已重新生成:${ident.model} ${ident.mac}")
}
/**
* 设备网络状态变化处理。
*
@@ -5158,7 +5231,8 @@ class MainActivity : AppCompatActivity() {
try {
var url = song.downloadUrl
if (url == null || url.isEmpty()) {
url = getSongDownloadUrl(extractTid(song))
// 4 参重载 = 顺带把歌名交给「App 点歌推送」(推送挂在取址汇合点里,见 SongApiClient)
url = getSongDownloadUrl(extractTid(song).orEmpty(), "720", false, song.title.orEmpty())
if (url != null) song.downloadUrl = url
}
if (url == null || url.isEmpty()) {
@@ -8723,6 +8797,12 @@ class MainActivity : AppCompatActivity() {
SettingsEntry(R.drawable.ott_ic_data_setting, "数据设置", "数据库、歌曲下载、歌曲文件相关设置") { showSettingsSection(1) },
SettingsEntry(R.drawable.ott_ic_play, "播放设置", "播放器、视频窗口相关设置") { showSettingsSection(2) },
SettingsEntry(R.drawable.ott_ic_sound, "声音设置") { showSoundSettingsDialog() },
SettingsEntry(
R.drawable.ott_ic_wifi_setting,
"设备身份",
"伪装为安卓盒子:mac/sn 由内置盒子模板随机生成,生成一次后固定不变",
"重新生成",
) { showDeviceIdentityDialog() },
SettingsEntry(
R.drawable.ott_ic_data_upgrade,
"应用更新",
@@ -8780,6 +8860,16 @@ class MainActivity : AppCompatActivity() {
}, checked = autoDeleteSongs),
SettingsEntry(R.drawable.ott_ic_data_upgrade, "重置数据库", "删除本地数据库并重新下载", "重置") { confirmResetDatabase() },
SettingsEntry(R.drawable.ott_ic_setting_storage_space, "硬盘读写权限") { toast(storageStatusText()) },
SettingsEntry(
R.drawable.ott_ic_data_setting,
"推送下载",
if (store.pushServerUrl.isBlank()) {
"未配置(默认关闭):点歌时不向自建服务器推送下载地址"
} else {
"已开启:点歌地址推送至 ${store.pushServerUrl}"
},
"设置",
) { showPushDownloadSettings() },
)
2 -> listOf(
SettingsEntry(R.drawable.ott_ic_download_setting, "开机清空下载列表", "开机时删除本地已下载歌曲并清理数据库状态", action = { clearDownloadsOnBoot = !clearDownloadsOnBoot; saveState() }, checked = clearDownloadsOnBoot),
@@ -9263,6 +9353,42 @@ class MainActivity : AppCompatActivity() {
}.create().showForTv()
}
/**
* 「推送下载」设置:把点歌取到的下载地址同步推给自建服务器(服务端 `POST /push_download`)。
*
* ⚠️ **地址留空 = 关闭该功能**(默认):点歌时连请求都不发(见 [PushDownloadClient])。
* 改完立刻生效(推送时现读 [store]),不需要重启。
*/
private fun showPushDownloadSettings() {
val box = LinearLayout(this)
box.orientation = LinearLayout.VERTICAL
box.setPadding(dp(32), dp(8), dp(32), dp(16))
val urlInput = EditText(this)
urlInput.setSingleLine(true)
urlInput.setText(store.pushServerUrl)
urlInput.setHint("如 http://192.168.1.10:6677(留空 = 关闭)")
val tokenInput = EditText(this)
tokenInput.setSingleLine(true)
tokenInput.setText(store.pushToken)
tokenInput.setHint(KtvStore.DEFAULT_PUSH_TOKEN)
box.addView(label("自建服务器地址(留空 = 关闭,点歌时不发任何请求)", 14, Color.WHITE))
box.addView(urlInput, LinearLayout.LayoutParams(-1, dp(58)))
box.addView(label("鉴权串(需与服务端 push_token 一致,默认 local)", 14, Color.WHITE))
box.addView(tokenInput, LinearLayout.LayoutParams(-1, dp(58)))
AlertDialog.Builder(this)
.setTitle("推送下载(App 点歌 → 自建服务器)")
.setView(box)
.setMessage("点歌拿到下载地址后,后台异步推送一份给自建服务器(8 秒超时、失败只写本地日志,不影响播放)。服务器会先校验地址、再按编号去重。")
.setNegativeButton("取消", null)
.setPositiveButton("保存") { _: DialogInterface?, _: Int ->
store.pushServerUrl = urlInput.text.toString().trim()
store.pushToken = tokenInput.text.toString().trim().ifEmpty { KtvStore.DEFAULT_PUSH_TOKEN }
saveState()
toast(if (store.pushServerUrl.isEmpty()) "已关闭推送下载" else "已开启推送下载")
showSettingsSection(1, false)
}.create().showForTv()
}
private fun showReserveStorageDialog() {
val totalGb = downloadedSongDirectory().totalSpace.toDouble() / 1024.0 / 1024.0 / 1024.0
val values = listOf(0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0)
@@ -10760,15 +10886,16 @@ class MainActivity : AppCompatActivity() {
}
companion object {
// ===== 歌曲源设备身份(用于 w.w345.my 等歌曲源的保活/认证伪装) =====
// 歌曲源通过共享后台按 device 判定合法性, App 会向 hbeat.ktvsky.com
// ===== 歌曲源设备身份(用于歌曲源的保活/认证伪装) =====
// 歌曲源通过共享后台按 device 识别设备, App 会向 hbeat.ktvsky.com
// 周期心跳(whoami)维持设备活跃;本 App 若不做心跳,设备可能被判为不合法而限流。
// 设备 MAC 取本机「当前在用网卡」的硬件地址(见 DeviceMac, 读不到时回退历史抓包值);
// 抓包时原版 App 的 _mac / androidid / _cid 三者同值, 这里保持该一致性。
// sn 见 KtvJsBridge.DEVICE_SN。
private const val LEGACY_PACKAGE = "com.origjoy.local.ktv"
private const val LEGACY_KTV_ID = "10006789" // 门店/包厢 id
private const val HEARTBEAT_INTERVAL_MS = 3600_000L
// ⚠️ mac/sn **不再是真实网卡地址/抓包常量**,而是 DeviceIdentity 里那份"伪装成安卓盒子"
// 的构造身份(内置盒子模板 + 随机 mac/sn,生成一次、落盘后固定不变):
// * whoami 心跳的 `_mac`/`androidid`/`_cid`(抓包时三者同值);
// * JS 与原生兜底取址的 `mac`/`device`(见 KtvJsBridge/SongUrlResolver);
// 三处同源。真实机型/真实网卡只写本地日志(见 DeviceMac 与 onCreate 的 DEVICE-ID 行)。
// ⚠️ 心跳的字段构造/响应解析已抽到 Whoami(纯 JVM 逻辑, 有单测覆盖):
// 包名用设置项 store.whoamiPkn, 间隔用响应里的 next_interval(默认 3600s)。
/** 公放候选预热时,外部目录扫描(可能遍历 /storage/ 各挂载点)的超时上限。 */
private const val RANDOM_SCAN_TIMEOUT_MS = 4_000L
@@ -0,0 +1,128 @@
package com.local.ktv
import java.net.URLEncoder
import java.util.LinkedHashMap
/**
* 「App 点歌推送」的**纯逻辑**(不碰 Android API,可跑纯 JVM 单测)。
*
* ## 这是什么
* App 点歌时已经向上游取到了一条可用的媒体地址(`…/<编号>.ts?token=…`)。把这**同一个地址**
* 顺带推一份给自建服务器(`POST <pushServerUrl>/push_download`),服务器就能省掉一次上游取址
* (取址有 5~10 秒的全局串行节奏、还会被上游风控),直接按这条地址下载入库。
* 服务器侧会**先校验地址闸门、再按编号去重**,所以这里不做重活(见服务端 ktv/pushdl.py)。
*
* ## 本文件只放"能单测的逻辑"
* 端点拼接 / body 拼接 / 5 分钟去重 / "配置为空就绝不调用发送器" —— 全是纯函数;
* 真正发请求(后台线程 + 8 秒超时 + HttpURLConnection)在 [PushDownloadClient]。
*
* ⚠️ 默认**关闭**:`pushServerUrl` 为空时 [endpoint] 返回 null,调用方连线程都不起、**绝不发请求**。
*/
object PushDownload {
/** 与服务端 `push_token` 的默认值保持一致。 */
const val DEFAULT_TOKEN = "local"
/** 同一编号的**内存去重窗口**(5 分钟):只为减少请求,服务端还会再判一次。 */
const val DEDUPE_MS = 5 * 60_000L
/** 单次推送的超时(连接 + 读取),见 [PushDownloadClient]。 */
const val TIMEOUT_MS = 8_000
/** 去重表最多记多少个编号(长跑不涨内存;超了先丢掉过期的)。 */
private const val MAX_REMEMBERED = 512
private val lock = Any()
private val recent = LinkedHashMap<String, Long>()
/**
* 端点拼接:`<配置去掉尾部 />/push_download`。
*
* 返回 **null = 功能关闭或配置非法**(空串、非 http(s)):调用方据此**完全不发请求**。
*/
@JvmStatic
fun endpoint(serverUrl: String?): String? {
val base = (serverUrl ?: "").trim().trimEnd('/')
if (base.isEmpty()) return null
if (!base.startsWith("http://") && !base.startsWith("https://")) return null
return "$base/push_download"
}
/**
* 请求体:`application/x-www-form-urlencoded`(App 侧最省事,服务端两种 body 都收)。
*
* 字段固定为 `tid / url / name / token` —— `url` 里的 `?token=…&t=…` 必须**整体编码**,
* 否则会把 query 拆成更多表单字段(服务端就只能拿到半截地址)。
*/
@JvmStatic
fun buildForm(tid: Long, url: String, name: String, token: String): String {
val tk = if (token.isEmpty()) DEFAULT_TOKEN else token
return "tid=" + enc(tid.toString()) +
"&url=" + enc(url) +
"&name=" + enc(name) +
"&token=" + enc(tk)
}
/** 同一编号 [DEDUPE_MS] 内是否已经推过(true = 本次不再发请求)。 */
@JvmStatic
fun shouldSkip(tid: Long, nowMs: Long): Boolean = synchronized(lock) {
val last = recent[tid.toString()] ?: return false
nowMs - last < DEDUPE_MS
}
/** 记一次"已推送"(**发送之前**就记:连续点同一首歌不重复发;失败也不在 5 分钟内重试)。 */
@JvmStatic
fun markPushed(tid: Long, nowMs: Long) {
synchronized(lock) {
recent[tid.toString()] = nowMs
if (recent.size > MAX_REMEMBERED) {
val cut = nowMs - DEDUPE_MS * 4
val it = recent.entries.iterator()
while (it.hasNext()) {
if (it.next().value < cut) it.remove()
}
}
}
}
/** 单测用:清掉去重记忆。 */
@JvmStatic
fun clearRecent() {
synchronized(lock) { recent.clear() }
}
/** 单测用:当前去重表里的编号数。 */
@JvmStatic
fun recentSize(): Int = synchronized(lock) { recent.size }
/**
* 提交一次推送。**所有判断都在 [sender] 之前**:
* `tid <= 0` / `url` 为空 / 配置为空 / 该编号刚推过 → 直接返回 false 且**不调用 [sender]**。
*
* @param sender 真正的发送动作(生产 = HttpURLConnection,单测 = 假实现),返回 true = 服务端 2xx。
* @return true = 确实发出去且服务器返回 2xx。
*/
@JvmStatic
fun submit(tid: Long, url: String, name: String, serverUrl: String?, token: String?,
nowMs: Long, sender: PushSender): Boolean {
if (tid <= 0L || url.isEmpty()) return false
val target = endpoint(serverUrl) ?: return false
if (shouldSkip(tid, nowMs)) return false
markPushed(tid, nowMs)
return try {
sender.send(target, buildForm(tid, url, name, token.orEmpty()))
} catch (t: Throwable) {
false
}
}
private fun enc(s: String): String = try {
URLEncoder.encode(s, "UTF-8")
} catch (_: Exception) {
s
}
}
/** 可注入的发送器:Kotlin `fun interface`,Java 单测里可直接用 lambda。 */
fun interface PushSender {
fun send(endpoint: String, form: String): Boolean
}
@@ -0,0 +1,86 @@
package com.local.ktv
import java.net.HttpURLConnection
import java.net.URL
/**
* 「App 点歌推送」的 **Android 侧胶水**:读设置 → 后台线程 → 8 秒超时 → fire-and-forget。
*
* ## 切入点
* 由 [SongApiClient.getSongDownloadUrl] 在**拿到下载地址之后**调用(那是 JS / 原生 / DB 回退
* 四条取址路径唯一的汇合点)。本类的每一步都**不能影响点歌**:
* * 默认关闭:`pushServerUrl` 为空 → [PushDownload.endpoint] 为 null → **连线程都不起、绝不发请求**
* (现有用户完全无感);
* * 5 分钟内存去重([PushDownload.shouldSkip]):同一首歌反复点/重复取址只推一次;
* * 后台守护线程 + 8 秒超时,**失败只写本地日志**([MiliLog.localWarn],不上报远程、不弹提示)。
*
* ⚠️ 每次推送会 `KtvStore().load()` 读一次设置(在后台线程里),保证"设置页改完立刻生效",
* 不用为了一个可选的旁路功能把 store 实例透传到取址层。
*/
object PushDownloadClient {
/**
* 点歌拿到下载地址后调用(幂等、可重入;调用方不必关心结果)。
*
* @param musicno 点歌编号(非数字/空 → 直接放弃:服务端只认正整数编号)
* @param url 刚取到的媒体地址(空 → 放弃)
* @param name 歌名(可选,只用于服务端日志/核对,不入库)
*/
@JvmStatic
fun offer(musicno: String?, url: String?, name: String? = "") {
val tid = musicno?.trim()?.toLongOrNull() ?: return
if (tid <= 0L || url.isNullOrEmpty()) return
if (PushDownload.shouldSkip(tid, System.currentTimeMillis())) return // 廉价短路:连线程都不起
val thread = Thread {
runCatching { pushNow(tid, url, name.orEmpty()) }
.onFailure { MiliLog.localWarn("PUSH-DL 推送异常 编号=$tid ${it.javaClass.simpleName}: ${it.message}") }
}
thread.isDaemon = true
thread.name = "push-download"
thread.start()
}
/** 后台线程里的实际动作:读设置 → 判空 → 提交(失败只写本地日志)。 */
private fun pushNow(tid: Long, url: String, name: String) {
val store = KtvStore()
store.load()
val serverUrl = store.pushServerUrl
if (PushDownload.endpoint(serverUrl) == null) return // 默认关闭:不发请求(连 POST 都不构造)
val ok = PushDownload.submit(tid, url, name, serverUrl, store.pushToken,
System.currentTimeMillis()) { endpoint, form -> post(endpoint, form) }
if (ok) {
// 成功也只写**本地**日志(远程日志只留结论,见 AGENTS.md 的日志五条约定)
MiliLog.local("PUSH-DL 已推送 编号=$tid 服务器=${PushDownload.endpoint(serverUrl)}")
}
}
/** 同步 POST(**只能在后台线程调用**):8 秒超时,返回 true = 服务端 2xx。 */
private fun post(endpoint: String, form: String): Boolean {
var conn: HttpURLConnection? = null
return try {
conn = URL(endpoint).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.connectTimeout = PushDownload.TIMEOUT_MS
conn.readTimeout = PushDownload.TIMEOUT_MS
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
conn.setRequestProperty("User-Agent", "MaidongKTV/1.2")
conn.outputStream.use { it.write(form.toByteArray(Charsets.UTF_8)) }
val code = conn.responseCode
// 响应体只在失败时用来写日志;**绝不打印 form**(里面有推送令牌与媒体地址的 token)
val body = runCatching {
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
stream?.use { String(it.readBytes(), Charsets.UTF_8) }
}.getOrNull()
if (code !in 200..299) {
MiliLog.localWarn("PUSH-DL 推送失败 端点=$endpoint HTTP $code ${body?.take(120).orEmpty()}")
}
code in 200..299
} catch (t: Throwable) {
MiliLog.localWarn("PUSH-DL 推送失败 端点=$endpoint ${t.javaClass.simpleName}: ${t.message}")
false
} finally {
runCatching { conn?.disconnect() }
}
}
}
+5 -1
View File
@@ -75,7 +75,11 @@ class Song {
fun getDownloadUrl(): String {
downloadUrl?.takeIf(String::isNotEmpty)?.let { return it }
SongApiClient.getSongDownloadUrl(filename?.substringBeforeLast('.') ?: id)?.takeIf(String::isNotEmpty)?.let {
// 「App 点歌推送」的切入点就在 getSongDownloadUrl 里;这里把歌名捎过去(仅用于推送日志)。
// 注:原来用可空重载挡空编号,现在由 4 参版本的 `musicno.isBlank()` 守卫承担,行为不变。
SongApiClient.getSongDownloadUrl(
(filename?.substringBeforeLast('.') ?: id).orEmpty(), "720", false, title.orEmpty(),
)?.takeIf(String::isNotEmpty)?.let {
downloadUrl = it
return it
}
@@ -50,7 +50,35 @@ object SongApiClient {
musicno?.takeIf(String::isNotBlank)?.let { getSongDownloadUrl(it, "720", false) }
@JvmStatic
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? {
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? =
getSongDownloadUrl(musicno, resolution, h265, "")
/**
* 取址(**四条路径唯一的汇合点**)+ 「App 点歌推送」切入点。
*
* 为什么把推送挂在这里(而不是 JS/原生各自的成功分支):能拿到下载地址的路径有四条
* (JS 桥 / 原生兜底 / DB 回退 / 主线程 DB 兜底),只有本方法能保证"**只要用户真的拿到了
* 一条可信下载地址,就恰好推送一次**";挂在别处必然漏掉某条路径(历史上广告闸门就是这么漏的)。
*
* 推送本身是 fire-and-forget(见 [PushDownloadClient]):默认关闭、后台线程、8 秒超时、
* 失败只写本地日志,**不阻塞播放/下载,也不改返回值**。
*
* @param songName 歌名(可选,仅用于推送日志与服务器核对,不入库)
*/
@JvmStatic
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean, songName: String): String? {
val url = resolveSongDownloadUrl(musicno, resolution, h265)
if (!url.isNullOrEmpty() && musicno.isNotBlank()) {
runCatching { PushDownloadClient.offer(musicno.trim(), url, songName) }
.onFailure { Log.w(TAG, "push download skipped", it) }
}
return url
}
/** 真正的取址实现(推送不参与其逻辑,见上面的入口)。 */
private fun resolveSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? {
// 空编号必须提前挡掉(推送的 `tid` 也要求非空),否则会拿空编号去扫一遍上游源
if (musicno.isBlank()) return null
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
// ⚠️ 主线程**绝不能**走网络路径:原生兜底最多 7 个源 × 8 秒超时,会把 UI 线程
// 卡死到 ANR(1.4.7 曾把这里改成先 resolveNatively 再回退,属于隐患)。
@@ -127,9 +155,15 @@ object SongApiClient {
return null
}
/** 快速判断是否为 demo URL (不含 JS 的完整逻辑, 仅做硬特征兜底) */
/**
* 快速判断是否为 demo URL (不含 JS 的完整逻辑, 仅做硬特征兜底)。
*
* ⚠️ host 一律用 [AdVideoFilter.hostOf](**已剥离 query/fragment,且 URI 解析失败会退化**):
* 旧实现直接 `java.net.URI(url).host`,query 里出现 `|`/空格这类字符时 `URI()` 抛异常,
* 被 `?: return true` 当成 demo —— 合法真源地址会因此被拒("取址失败"的隐蔽来源)。
*/
private fun isLikelyDemoUrl(url: String): Boolean {
val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return true
val host = AdVideoFilter.hostOf(url) ?: return true
// 纯 IP 地址 → 高概率 demo
if (host.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"))) return true
// Cloudflare R2 demo bucket (pub-*.r2.dev)
@@ -264,7 +264,10 @@ object SongOkDownloadManager {
purgeCachedSongFile(song, "untrusted-cached-url")
}
}
return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url ->
// 「App 点歌推送」的切入点就在 getSongDownloadUrl 里(JS/原生/DB 三条取址路径的汇合点):
// 这里把歌名捎过去,仅用于推送日志与服务端核对(musicNo 为空时该方法会直接返回 null)。
return SongApiClient.getSongDownloadUrl(musicNo.orEmpty(), "720", false, song.title.orEmpty())
.orEmpty().also { url ->
// 请求源 = 本次点歌实际命中的源(JS 上报); 取不到时留空。
val srcHost = SongApiClient.lastSongSourceHost()
// ⚠️ 广告漏放的**最终兜底防线**:不管地址来自 JS 取址、原生兜底还是 DB 回退,
@@ -86,7 +86,16 @@ object SongUrlResolver {
@Volatile private var config: SourceConfig? = null
@Volatile private var configAt: Long = 0L
@Volatile private var sn: String = KtvJsBridge.DEVICE_SN
/**
* 上报给源站的设备身份 `sn`/`mac`:**与 whoami 心跳、`i.php` 同一份**
* (`DeviceIdentity` 生成一次、落盘后固定不变)。
*
* ⚠️ 这是**构造值**(真实盒子厂商 OUI + 随机后缀),不是网卡真实 MAC ——
* Android 10+ 拿不到真实 MAC,也不该拿真实机型去上报(见 [DeviceIdentity])。
*/
private val sn: String get() = DeviceIdentity.effective().sn
private val mac: String get() = DeviceIdentity.effective().mac
/** 本轮取址是否遇到过 HTTP 403(源站风控的直接信号)。 */
@Volatile private var sawForbidden = false
@@ -194,7 +203,7 @@ object SongUrlResolver {
return null
}
sawForbidden = false
val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn)
val mac = this.mac // 与 whoami 心跳 / do.php 同一份设备身份
val startedAt = System.currentTimeMillis()
val allHosts = synchronized(hostOrder) { hostOrder.toList() }
// 原版取址前会先 POST api.php 取配置;这里顺带用它限制一次取址的源数量
@@ -350,23 +359,19 @@ object SongUrlResolver {
}
/**
* 原生路径的可信校验:先过 `AdVideoFilter.isTrustedSongUrl`(广告域名/路径 + `.ts` 文件名
* 必须等于点歌编号),再补上 JS `isDemoUrl` 里那几条(wb66/demo、纯 IP、R2 测试桶)。
* 原生路径的可信校验:直接委托给 `AdVideoFilter.isTrustedSongUrl` —— 它现在是**唯一闸门**,
* 已涵盖原版 `isDemoUrl` 的全部规则(wb66/demo、`*.ac16.vip`、`/ad_files/`、`my_ad_video`、
* 裸 IP、`pub-*.r2.dev/ss/`、`.ts` 文件名必须等于点歌编号)。
*
* ⚠️ 这些规则只在 **host + path** 上判定(`AdVideoFilter.hostOf/pathOf` 已剥离
* query/fragment):真源地址带 `?token=…&t=…`(2026-10-11 抓包),拿整串搜 `wb66`/`demo`
* 会把 query 里的签名/随机串当广告特征(误杀真源)—— 这正是"地址不可信"的隐蔽来源之一。
* 规则收敛到一处后,JS / 原生 / DB 回退 / 下载兜底四条路径的判定不会再漂移。
*/
private fun isValidSongUrl(url: String, musicno: String): Boolean {
if (!AdVideoFilter.isTrustedSongUrl(url, musicno)) return false
val lower = url.lowercase()
if (lower.contains("wb66") || lower.contains("demo")) return false
if (IP_URL.containsMatchIn(url)) return false
if (R2_DEMO.containsMatchIn(url)) return false
return true
}
private fun isValidSongUrl(url: String, musicno: String): Boolean =
AdVideoFilter.isTrustedSongUrl(url, musicno)
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(?:\\?|$)")
}
+155
View File
@@ -0,0 +1,155 @@
package com.local.ktv
import java.net.URLEncoder
/**
* 原版「时光KTV」的 **whoami 设备心跳**上报(复刻 2026-10-11 抓包)。
*
* ## 为什么要有它
* 抓包里官方 App 是**三个**请求:`POST hbeat.ktvsky.com/whoami`(设备登记/保活)→
* `GET /i.php`(取 token)→ `GET /music/do.php`(取歌地址)。若设备被上游判为
* "不合法/不活跃",就会只下发广告地址(wb66 之类)。
*
* ## 为什么单独放一个 object(而不是写在 MainActivity 里)
* 这里的逻辑是**纯函数**(拼 query / 拼 URL / 解析响应),不依赖 `Context`/`Build`,
* 因此能在 **JVM 单测**(`app/src/test`)里逐字段验证;Android 相关的取值
* (`Build.MODEL` 等)由调用方通过 [DeviceFacts] 传进来。
*
* ⚠️ 参数顺序、编码方式与抓包**逐字段对齐**:`/`→`%2F`、`:`→`%3A`(`URLEncoder` 还会把
* 空格编成 `+`,抓包里没有带空格的值)。
*/
object Whoami {
/** 上游心跳地址(抓包实测)。 */
const val DEFAULT_URL = "http://hbeat.ktvsky.com/whoami"
/** `_pkn`:伪装成原版包名(可被设置项 [KtvStore.whoamiPkn] 覆盖)。 */
const val DEFAULT_PKN = "com.origjoy.local.ktv"
/** `ktv_id`:门店/包厢 id(抓包值)。 */
const val DEFAULT_KTV_ID = "10006789"
/** `_src` / `_src_at`:渠道来源与归因渠道(抓包值)。 */
const val SRC = "600007"
const val SRC_AT = "600001"
/** `_vc`:原版客户端 versionCode。 */
const val VC = 4
/** `_vn`:与原版取址协议(i.php / do.php)同一口径,见 [SongUrlResolver]。 */
const val VN = "4.1.3.03281430"
/** 响应里没给 `next_interval` 时的兜底间隔(秒)。 */
const val DEFAULT_INTERVAL_SEC = 3600
/** `next_interval` 的合法区间:太短会把上游打疯,太长会掉线。 */
const val MIN_INTERVAL_SEC = 60
const val MAX_INTERVAL_SEC = 24 * 3600
/** 一次心跳的超时(小 POST,不需要取址那种长超时)。 */
const val TIMEOUT_MS = 8_000
/**
* 设备事实:Android 侧由调用方填 `Build.*`,单测里填字面量。
* 抓包时 `_mac` / `androidid` / `_cid` 三者同值(`_cid` 带 `A_` 前缀),保持一致。
*/
data class DeviceFacts(
val model: String,
val manufacturer: String,
val product: String,
val device: String,
val board: String,
val hardware: String,
val fingerprint: String,
val incremental: String,
val release: String,
val sdkInt: Int,
val androidId: String,
val mac: String,
/** 机型/厂商/包厢号/包名的覆盖入口;空串表示用默认值。 */
val pkn: String = DEFAULT_PKN,
val ktvId: String = DEFAULT_KTV_ID,
)
/** 抓包里的参数顺序(上游若按位置解析也不会错)。 */
@JvmField
val PARAM_ORDER = listOf(
"_src", "_src_at", "_m", "_mac", "_vc", "_vn", "_pkn", "_player", "_boot", "_ts",
"_api", "ktv_id", "androidid", "_manuf", "_cid", "d_name", "d_device", "d_board",
"d_hardware", "d_fingerprint", "d_incremental", "d_release", "d_utc", "d_user",
)
/**
* 按抓包顺序生成 24 个 query 参数。
*
* @param boot `_boot`:**启动那一次**为 1,之后周期上报为 0(与服务器侧 `ktv/whoami.py` 一致)。
* @param nowMs `_ts` 与 `d_utc` 都用它(抓包两者同值)。
*/
@JvmStatic
fun buildQuery(facts: DeviceFacts, nowMs: Long, boot: Boolean): LinkedHashMap<String, String> {
val ts = nowMs.toString()
val values = mapOf(
"_src" to SRC,
"_src_at" to SRC_AT,
"_m" to facts.model,
"_mac" to facts.mac,
"_vc" to VC.toString(),
"_vn" to VN,
"_pkn" to facts.pkn.ifEmpty { DEFAULT_PKN },
"_player" to "0",
"_boot" to if (boot) "1" else "0",
"_ts" to ts,
"_api" to facts.sdkInt.toString(),
"ktv_id" to facts.ktvId.ifEmpty { DEFAULT_KTV_ID },
"androidid" to facts.androidId,
"_manuf" to facts.manufacturer,
"_cid" to "A_" + facts.mac,
"d_name" to facts.product,
"d_device" to facts.device,
"d_board" to facts.board,
"d_hardware" to facts.hardware,
"d_fingerprint" to facts.fingerprint,
"d_incremental" to facts.incremental,
"d_release" to facts.release,
"d_utc" to ts,
"d_user" to "dpi",
)
// 用 LinkedHashMap:**插入顺序即抓包顺序**(Java 单测里同样能按序断言)
val out = LinkedHashMap<String, String>()
for (key in PARAM_ORDER) out[key] = values[key] ?: ""
return out
}
/** 拼完整 URL(百分号编码,与抓包一致)。 */
@JvmStatic
fun buildUrl(facts: DeviceFacts, nowMs: Long, boot: Boolean,
base: String = DEFAULT_URL): String {
val query = buildQuery(facts, nowMs, boot).entries.joinToString("&") { (k, v) ->
"$k=" + URLEncoder.encode(v, "UTF-8")
}
return "$base?$query"
}
/** 原版 UA:`Dalvik/2.1.0 (Linux; U; Android <版本>; <型号> Build/<构建>)`(抓包实测)。 */
@JvmStatic
fun userAgent(facts: DeviceFacts): String =
"Dalvik/2.1.0 (Linux; U; Android ${facts.release}; ${facts.model} Build/${facts.incremental})"
/**
* 从响应 JSON 里取 `next_interval`(秒);取不到返回 null → 调用方用
* [DEFAULT_INTERVAL_SEC],并统一夹到 [MIN_INTERVAL_SEC, MAX_INTERVAL_SEC]。
*
* 手写解析(不用 `JSONObject`)是为了让它保持**纯 JVM 逻辑**、单测零依赖。
*/
@JvmStatic
fun parseNextInterval(body: String?): Int? {
val text = body ?: return null
val m = Regex("\"next_interval\"\\s*:\\s*(\\d+)").find(text) ?: return null
return m.groupValues.getOrNull(1)?.toIntOrNull()
}
/** 把间隔夹到合法区间(HTTP 200 但没给 next_interval 时也走这里)。 */
@JvmStatic
fun clampInterval(sec: Int?): Int =
(sec ?: DEFAULT_INTERVAL_SEC).coerceIn(MIN_INTERVAL_SEC, MAX_INTERVAL_SEC)
}
@@ -2,7 +2,9 @@ package com.local.ktv;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
/**
@@ -77,4 +79,69 @@ public final class AdVideoFilterTest {
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://wb66.cn/f/d/7b7c9/7303235.ts", "7303235"));
}
// ─────────────────────────────────────────────────────────────
// 2026-10-11 抓包:媒体地址**带 query**(`…/<编号>.ts?token=…&t=…`)。
// 判定必须在"剥离 query/fragment 的 path"上做,否则两种错都会犯:
// * 带 `#fragment` 时旧正则 `(?:\?|$)` 完全匹配不到 → **整条文件名规则被跳过(漏放广告)**;
// * 拿整串搜 `wb66`/`demo` → query 里的签名/随机串把**真源误杀**("地址不可信"的来源之一)。
// ─────────────────────────────────────────────────────────────
/** 抓包里的真实真源地址(域名 txog.ktvsky.com + 查询串)→ 必须放行。 */
@Test
public void acceptsHarUrlWithQueryString() {
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://txog.ktvsky.com/264/lvbr/480/E/ts/39.6/202309070206/480p/"
+ "7200088.ts?token=432fe2b3345588df379f5e2da3100617&t=1791706901",
"7200088"));
}
/** 带 query / fragment 时,文件名比对依然生效(含旧实现漏放的 `#` 形态)。 */
@Test
public void filenameCheckWorksWithQueryAndFragment() {
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://cdn.example.com/x/9999999.ts?token=x", "7200088"));
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://cdn.example.com/x/9999999.ts#frag", "7200088"));
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://cdn.example.com/x/9999999.ts?token=x&t=1#frag", "7200088"));
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://cdn.example.com/x/7200088.ts?token=x&t=1#frag", "7200088"));
}
/** 带 query 时广告特征依旧拦下(wb66 / demo / ac16 / ad_files / r2.dev)。 */
@Test
public void adTargetsStillRejectedWithQuery() {
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://wb66.cn/f/d/7b7c9/10188555.ts?token=x", "7200088"));
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://wb66.cn/f/d/7b7c9/7200088.ts?token=x", "7200088"));
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://demo.example.com/x/7200088.ts?token=x", "7200088"));
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://gz.ac16.vip/ad_files/my_ad_video.ts?token=x", "7200088"));
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://pub-abc123.r2.dev/ss/7200088.ts?token=x", "7200088"));
}
/** query 里的随机串不参与广告特征匹配(避免误杀真源)。 */
@Test
public void queryValueWithDemoWordIsNotAFalseReject() {
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://txog.ktvsky.com/264/lvbr/480/E/ts/39.6/x/480p/7200088.ts"
+ "?note=demo&token=wb66", "7200088"));
}
/** host / path 的剥离:URI 因 query 含非法字符而抛异常时,也要能取出 host。 */
@Test
public void hostAndPathAreRobustToIllegalQueryChars() {
assertEquals("txog.ktvsky.com", AdVideoFilter.hostOf(
"http://txog.ktvsky.com/a/7200088.ts?token=x|y z"));
assertEquals("/a/7200088.ts", AdVideoFilter.pathOf(
"http://txog.ktvsky.com/a/7200088.ts?token=x|y z#frag"));
assertEquals("7200088", AdVideoFilter.tsBasename(
AdVideoFilter.pathOf("http://txog.ktvsky.com/a/7200088.ts?token=x&t=1#f")));
assertNull(AdVideoFilter.tsBasename(
AdVideoFilter.pathOf("http://txog.ktvsky.com/a/stream?token=x")));
}
}
@@ -0,0 +1,226 @@
package com.local.ktv;
import org.junit.Test;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
/**
* 设备身份({@link DeviceIdentity}:伪装成一台安卓盒子)回归测试。
*
* 依据:2026-10-11 抓包 —— 官方 App 的三个请求都带同一份设备身份
* (`whoami` 的 `_mac`/`androidid`/`_cid`、`i.php` 的 `mac=<mac>_<sn>`、
* `do.php` 的 `device=<mac>_<sn>`)。服务端对应件是
* `selfhost-source/server/ktv/identity.py`(模板与规则一一对应,两边都有测试锁住)。
*
* ⚠️ 这里必须保持**纯 JVM**(不碰 `Build`/`Context`/`org.json`):单测跑在 JVM 上,
* Android SDK 的那些类全是会抛 `Stub!` 的空壳。持久化因此测的是 `toJson()`/`fromJson()`
* 往返(真正落盘在 `KtvStore.deviceIdentityJson`,见 KtvStore.load/snapshotJson)。
*/
public final class DeviceIdentityTest {
/** 服务端的模板清单:写死在这里,任何一边增删模板都会让这个用例失败(防两侧漂移)。 */
private static final List<String> PRESET_NAMES =
Arrays.asList("aml_s905x", "aml_s905x3", "aml_s905x4", "rk3328", "rtd1295");
private static final Set<String> HEX = new HashSet<>(Arrays.asList(
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"));
private static boolean isHex(String text, int length) {
if (text == null || text.length() != length) return false;
for (int i = 0; i < text.length(); i++) {
if (!HEX.contains(String.valueOf(text.charAt(i)))) return false;
}
return true;
}
/** 与服务端 `identity.py` 的 TEMPLATES 同名的 5 套模板,且每套字段自洽。 */
@Test
public void templatesMatchServerSide() {
assertEquals(PRESET_NAMES.size(), DeviceIdentity.TEMPLATES.size());
for (DeviceIdentity.Template t : DeviceIdentity.TEMPLATES) {
assertTrue("模板名必须与服务端一致: " + t.getPreset(), PRESET_NAMES.contains(t.getPreset()));
assertNotNull(DeviceIdentity.PRESETS.get(t.getPreset()));
assertTrue(t.getLabel(), t.getLabel().contains(t.getManuf()));
assertTrue(t.getRelease(), Arrays.asList("9", "11", "12").contains(t.getRelease()));
assertFalse(t.getOuis().isEmpty());
for (String oui : t.getOuis()) {
assertTrue(t.getPreset() + " 的 OUI 必须是 6 位小写 hex: " + oui, isHex(oui, 6));
// OUI 第一字节最低位必须是 0,否则生成的 mac 是组播地址(一眼假)
assertEquals(0, Integer.parseInt(oui.substring(0, 2), 16) & 0x01);
assertTrue(DeviceIdentity.OUIS.contains(oui));
}
}
}
/** 生成合法性:mac/sn 形态、单播、OUI 归属、三处同源、指纹格式、_api 自洽。 */
@Test
public void generatedIdentityIsLegalAndSelfConsistent() {
for (int seed = 0; seed < 50; seed++) {
DeviceIdentity.Identity ident = DeviceIdentity.generate("", new Random(seed));
assertEquals("生成的身份必须自洽", Arrays.asList(), DeviceIdentity.validate(ident));
assertTrue(ident.getMac(), isHex(ident.getMac(), 12));
assertTrue(ident.getSn(), isHex(ident.getSn(), 12));
assertEquals("mac 必须是单播地址", 0,
Integer.parseInt(ident.getMac().substring(0, 2), 16) & 0x01);
assertTrue("OUI 必须是内置盒子厂商前缀",
DeviceIdentity.OUIS.contains(ident.getMac().substring(0, 6)));
assertEquals("抓包口径:androidid == _mac", ident.getMac(), ident.getAndroidId());
assertEquals("抓包口径:_cid == A_ + mac", "A_" + ident.getMac(), ident.getCid());
assertEquals(DeviceIdentity.VC, 4);
assertEquals(Whoami.VN, ident.getVn());
assertEquals(DeviceIdentity.androidRelease(ident.getApi()), ident.getRelease());
// 真机指纹形态:品牌/产品/设备:版本/构建ID/增量:user/release-keys
assertTrue(ident.getFingerprint(),
ident.getFingerprint().startsWith(ident.getManuf() + "/"));
assertTrue(ident.getFingerprint(), ident.getFingerprint().endsWith(":user/release-keys"));
assertTrue(ident.getFingerprint(),
ident.getFingerprint().contains(":" + ident.getRelease() + "/"));
assertEquals("ktv_id 是 8 位数字", 8, ident.getKtvId().length());
}
}
/** 指定模板时字段必须来自该模板;未指定时必须是内置模板之一;未知模板名回退随机。 */
@Test
public void presetSelectsMatchingTemplateFields() {
DeviceIdentity.Identity ident = DeviceIdentity.generate("rk3328", new Random(7));
assertEquals("rk3328", ident.getPreset());
assertEquals("Rockchip", ident.getManuf());
assertEquals("H96Max", ident.getModel());
assertEquals("rk3328_box", ident.getDevice());
assertEquals(28, ident.getApi());
assertTrue(DeviceIdentity.OUIS.contains(ident.getMac().substring(0, 6)));
assertEquals(Arrays.asList(), DeviceIdentity.validate(ident));
DeviceIdentity.Identity android12 = DeviceIdentity.generate("aml_s905x4", new Random(4));
assertEquals(31, android12.getApi());
assertEquals("12", android12.getRelease());
for (int seed = 0; seed < 20; seed++) {
DeviceIdentity.Identity random = DeviceIdentity.generate("", new Random(seed));
assertTrue(random.getPreset(), PRESET_NAMES.contains(random.getPreset()));
}
// 未知模板名回退成随机模板(不能抛异常,也不能生成半截身份)
DeviceIdentity.Identity fallback = DeviceIdentity.generate("不存在的模板", new Random(3));
assertTrue(fallback.getPreset(), PRESET_NAMES.contains(fallback.getPreset()));
assertEquals(Arrays.asList(), DeviceIdentity.validate(fallback));
}
/** 随机化生效(两次不同),且随机源可复现(同种子 → 同结果,便于排查)。 */
@Test
public void generationIsRandomButReproducibleWithSeed() {
DeviceIdentity.Identity a = DeviceIdentity.generate("aml_s905x", new Random(1));
DeviceIdentity.Identity b = DeviceIdentity.generate("aml_s905x", new Random(2));
assertNotEquals(a.getMac(), b.getMac());
assertNotEquals(a.getSn(), b.getSn());
assertNotEquals(a.getKtvId(), b.getKtvId());
assertEquals(a, DeviceIdentity.generate("aml_s905x", new Random(1)));
}
/** 持久化:`toJson()` → `fromJson()` 往返逐字段一致(KtvStore 里存的就是这串 JSON)。 */
@Test
public void jsonRoundTripKeepsIdentityStable() {
DeviceIdentity.Identity ident = DeviceIdentity.generate("aml_s905x3", new Random(9));
String json = ident.toJson();
DeviceIdentity.Identity back = DeviceIdentity.Identity.fromJson(json);
assertNotNull(back);
assertEquals("往返后必须逐字段一致", ident, back);
assertEquals(ident.getMac(), back.getMac());
assertEquals(ident.getSn(), back.getSn());
assertEquals(ident.getCid(), back.getCid());
assertEquals(ident.getFingerprint(), back.getFingerprint());
assertEquals("再序列化一次也必须稳定", json, back.toJson());
// 缺 mac/sn 的串必须被拒(宁可重新生成,也不能拿半截身份去上报)
assertNull(DeviceIdentity.Identity.fromJson("{\"_m\":\"A95X\"}"));
assertNull(DeviceIdentity.Identity.fromJson(""));
assertNull(DeviceIdentity.Identity.fromJson(null));
assertNull(DeviceIdentity.Identity.fromJson("不是 JSON"));
}
/** validate 必须能挑出"一眼假"的身份(与服务端同一套规则)。 */
@Test
public void validateCatchesBrokenIdentities() {
DeviceIdentity.Identity good = DeviceIdentity.generate("aml_s905x", new Random(5));
assertEquals(Arrays.asList(), DeviceIdentity.validate(good));
assertFalse(DeviceIdentity.validate(null).isEmpty());
assertFalse(DeviceIdentity.validate(brokenWithMac(good, "011ec0abcdef")).isEmpty()); // 组播地址
assertFalse(DeviceIdentity.validate(brokenWithMac(good, "abc")).isEmpty()); // 长度不对
assertFalse(DeviceIdentity.validate(brokenWithMac(good, "aabbccddeeff")).isEmpty()); // OUI 不认识
assertFalse(DeviceIdentity.validate(brokenWithSn(good, "zz")).isEmpty()); // 非 hex
assertFalse(DeviceIdentity.validate(brokenWithModel(good, "")).isEmpty()); // 机型为空
assertFalse(DeviceIdentity.validate(brokenWithFingerprint(good,
good.getFingerprint().replace(":user/release-keys", ":eng/test-keys"))).isEmpty());
assertFalse(DeviceIdentity.validate(brokenWithRelease(good, "13")).isEmpty()); // 与 _api 不自洽
}
/** whoami 心跳的设备事实必须来自这份身份(三个请求同源的最后一环)。 */
@Test
public void whoamiFactsComeFromIdentity() {
DeviceIdentity.Identity ident = DeviceIdentity.generate("aml_s905x4", new Random(11));
Whoami.DeviceFacts facts = ident.toFacts(Whoami.DEFAULT_PKN, "");
assertEquals(ident.getModel(), facts.getModel());
assertEquals(ident.getManuf(), facts.getManufacturer());
assertEquals(ident.getFingerprint(), facts.getFingerprint());
assertEquals(ident.getMac(), facts.getMac());
assertEquals(ident.getMac(), facts.getAndroidId());
assertEquals(ident.getApi(), facts.getSdkInt());
assertEquals(ident.getRelease(), facts.getRelease());
Map<String, String> query = Whoami.buildQuery(facts, 1700000000000L, true);
assertEquals(24, query.size());
assertEquals(ident.getMac(), query.get("_mac"));
assertEquals(ident.getMac(), query.get("androidid"));
assertEquals("A_" + ident.getMac(), query.get("_cid"));
assertEquals(ident.getModel(), query.get("_m"));
assertEquals(ident.getManuf(), query.get("_manuf"));
assertEquals(ident.getFingerprint(), query.get("d_fingerprint"));
assertEquals(ident.getRelease(), query.get("d_release"));
assertEquals(String.valueOf(ident.getApi()), query.get("_api"));
assertEquals(ident.getKtvId(), query.get("ktv_id"));
// i.php/do.php 用的 `mac=<mac>_<sn>` 与心跳的 `_mac` 同源(mac/sn 都来自这一份身份)
assertEquals(ident.getMac() + "_" + ident.getSn(), facts.getMac() + "_" + ident.getSn());
}
// ── 小工具:只替换一个字段(data class 的 copy 在 Java 里不好用)──
private static DeviceIdentity.Identity rebuild(DeviceIdentity.Identity b, String mac, String sn,
String model, String release, String fingerprint) {
return new DeviceIdentity.Identity(
b.getPreset(), b.getLabel(), mac, sn, model, b.getManuf(), b.getName(),
b.getDevice(), b.getBoard(), b.getHardware(), release, b.getApi(),
b.getBuildId(), b.getIncremental(), fingerprint, b.getDUser(),
b.getKtvId(), b.getPkn(), b.getVn(), b.getCreatedAt());
}
private static DeviceIdentity.Identity brokenWithMac(DeviceIdentity.Identity b, String mac) {
return rebuild(b, mac, b.getSn(), b.getModel(), b.getRelease(), b.getFingerprint());
}
private static DeviceIdentity.Identity brokenWithSn(DeviceIdentity.Identity b, String sn) {
return rebuild(b, b.getMac(), sn, b.getModel(), b.getRelease(), b.getFingerprint());
}
private static DeviceIdentity.Identity brokenWithModel(DeviceIdentity.Identity b, String model) {
return rebuild(b, b.getMac(), b.getSn(), model, b.getRelease(), b.getFingerprint());
}
private static DeviceIdentity.Identity brokenWithRelease(DeviceIdentity.Identity b, String release) {
return rebuild(b, b.getMac(), b.getSn(), b.getModel(), release, b.getFingerprint());
}
private static DeviceIdentity.Identity brokenWithFingerprint(DeviceIdentity.Identity b, String fp) {
return rebuild(b, b.getMac(), b.getSn(), b.getModel(), b.getRelease(), fp);
}
}
@@ -0,0 +1,174 @@
package com.local.ktv;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.net.URLDecoder;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.Before;
import org.junit.Test;
/**
* 「App 点歌推送」纯 JVM 单测(不需要 Android 设备/模拟器)。
*
* 覆盖需求里点名的四件事:
* ① URL 拼接(`<pushServerUrl>/push_download`);
* ② token 携带(默认 local;请求体是 x-www-form-urlencoded);
* ③ **空配置不发请求**(默认关闭:连 sender 都不调用);
* ④ 同一编号 5 分钟内存去重。
*/
public class PushDownloadTest {
/** 假发送器:记下调用次数与最后一次的 (endpoint, form)。 */
private static final class FakeSender implements PushSender {
final AtomicInteger calls = new AtomicInteger();
String endpoint;
String form;
boolean result = true;
RuntimeException boom;
@Override
public boolean send(String endpoint, String form) {
calls.incrementAndGet();
this.endpoint = endpoint;
this.form = form;
if (boom != null) throw boom;
return result;
}
}
/** 把 x-www-form-urlencoded 解析成 map(用于断言字段,而不是比对整串)。 */
private static Map<String, String> parseForm(String form) throws Exception {
Map<String, String> out = new HashMap<String, String>();
for (String pair : form.split("&")) {
int eq = pair.indexOf('=');
assertTrue("表单字段缺少 = : " + pair, eq > 0);
out.put(pair.substring(0, eq), URLDecoder.decode(pair.substring(eq + 1), "UTF-8"));
}
return out;
}
@Before
public void setUp() {
PushDownload.clearRecent();
}
// ── ① URL 拼接 ──
@Test
public void endpointAppendsPushDownload() {
assertEquals("http://192.168.1.10:6677/push_download",
PushDownload.endpoint("http://192.168.1.10:6677"));
assertEquals("http://192.168.1.10:6677/push_download",
PushDownload.endpoint(" http://192.168.1.10:6677/ ")); // 去空白 + 去尾斜杠
assertEquals("https://ktv.example.com/base/push_download",
PushDownload.endpoint("https://ktv.example.com/base/"));
}
@Test
public void endpointIsNullWhenDisabledOrMalformed() {
assertNull(PushDownload.endpoint(null));
assertNull(PushDownload.endpoint(""));
assertNull(PushDownload.endpoint(" "));
// 没写协议 = 配置错误:按"关闭"处理,绝不拿它去连(否则会当成相对路径/抛异常)
assertNull(PushDownload.endpoint("192.168.1.10:6677"));
assertNull(PushDownload.endpoint("ftp://192.168.1.10"));
}
// ── ② token 携带 / body 形状 ──
@Test
public void formCarriesTidUrlNameToken() throws Exception {
String url = "http://cdn.example.com/480p/7200088.ts?token=abc&t=1700000000";
Map<String, String> f = parseForm(PushDownload.buildForm(7200088L, url, "一帘好梦", "local"));
assertEquals("7200088", f.get("tid"));
assertEquals(url, f.get("url")); // 整条地址(含它自己的 query)必须原样还原
assertEquals("一帘好梦", f.get("name"));
assertEquals("local", f.get("token"));
}
@Test
public void emptyTokenFallsBackToLocal() throws Exception {
assertEquals("local", parseForm(PushDownload.buildForm(1L, "http://h/1.ts", "", "")).get("token"));
// 提交时 token 为 null 也一样(服务端默认 push_token = "local")
FakeSender sender = new FakeSender();
assertTrue(PushDownload.submit(1L, "http://h/1.ts", "", "http://h:6677", null, 1000L, sender));
assertEquals("local", parseForm(sender.form).get("token"));
}
// ── ③ 空配置:绝不发请求 ──
@Test
public void emptyConfigNeverCallsSender() {
FakeSender sender = new FakeSender();
assertFalse(PushDownload.submit(1L, "http://h/1.ts", "n", "", "local", 1000L, sender));
assertFalse(PushDownload.submit(1L, "http://h/1.ts", "n", null, "local", 1000L, sender));
assertFalse(PushDownload.submit(1L, "http://h/1.ts", "n", " ", "local", 1000L, sender));
assertFalse(PushDownload.submit(1L, "http://h/1.ts", "n", "192.168.1.10:6677", "local", 1000L, sender));
assertEquals("默认关闭时不能发任何请求", 0, sender.calls.get());
assertEquals(0, PushDownload.recentSize()); // 也没记去重
}
@Test
public void emptyUrlOrBadTidNeverCallsSender() {
FakeSender sender = new FakeSender();
assertFalse(PushDownload.submit(1L, "", "n", "http://h:6677", "local", 1000L, sender));
assertFalse(PushDownload.submit(0L, "http://h/1.ts", "n", "http://h:6677", "local", 1000L, sender));
assertFalse(PushDownload.submit(-5L, "http://h/1.ts", "n", "http://h:6677", "local", 1000L, sender));
assertEquals(0, sender.calls.get());
}
// ── ④ 5 分钟去重 ──
@Test
public void dedupeSameTidWithinFiveMinutes() {
FakeSender sender = new FakeSender();
long t0 = 1_700_000_000_000L;
assertTrue(PushDownload.submit(7L, "http://h/7.ts", "n", "http://h:6677", "local", t0, sender));
assertFalse("5 分钟内同编号不该再发", PushDownload.submit(7L, "http://h/7.ts", "n", "http://h:6677", "local", t0 + 1_000L, sender));
assertFalse(PushDownload.submit(7L, "http://h/7.ts", "n", "http://h:6677", "local", t0 + PushDownload.DEDUPE_MS - 1, sender));
assertEquals(1, sender.calls.get());
// 过了 5 分钟 → 允许再推一次(长片单里同一首歌可能被重新点)
assertTrue(PushDownload.submit(7L, "http://h/7.ts", "n", "http://h:6677", "local", t0 + PushDownload.DEDUPE_MS + 1, sender));
assertEquals(2, sender.calls.get());
}
@Test
public void dedupeIsPerTid() {
FakeSender sender = new FakeSender();
assertTrue(PushDownload.submit(7L, "http://h/7.ts", "n", "http://h:6677", "local", 1000L, sender));
assertTrue(PushDownload.submit(8L, "http://h/8.ts", "n", "http://h:6677", "local", 1000L, sender));
assertEquals(2, sender.calls.get());
assertEquals(2, PushDownload.recentSize());
}
@Test
public void endpointAndFormArePassedToSender() throws Exception {
FakeSender sender = new FakeSender();
String url = "http://cdn.example.com/480p/9.ts?token=x&t=1";
assertTrue(PushDownload.submit(9L, url, "歌名", "http://ktv.local:6677/", "tk", 5000L, sender));
assertEquals("http://ktv.local:6677/push_download", sender.endpoint);
Map<String, String> f = parseForm(sender.form);
assertEquals("9", f.get("tid"));
assertEquals(url, f.get("url"));
assertEquals("歌名", f.get("name"));
assertEquals("tk", f.get("token"));
assertNotNull(sender.form);
}
@Test
public void senderFailureOrThrowIsSwallowed() {
FakeSender failing = new FakeSender();
failing.result = false;
assertFalse(PushDownload.submit(11L, "http://h/11.ts", "n", "http://h:6677", "local", 1000L, failing));
FakeSender boom = new FakeSender();
boom.boom = new IllegalStateException("network down");
assertFalse("推送异常绝不能冒到调用方", PushDownload.submit(12L, "http://h/12.ts", "n", "http://h:6677", "local", 1000L, boom));
}
}
@@ -0,0 +1,164 @@
package com.local.ktv;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
/**
* whoami 心跳([Whoami])的字段与解析回归测试。
*
* 依据:2026-10-11 抓包(`POST hbeat.ktvsky.com/whoami?<24 个字段>`,响应
* `{"msg":"","code":200,"next_interval":3600}`)。原版启动后打一次、之后按
* `next_interval` 周期续报,`_boot=1` 只在启动那次。
*
* ⚠️ 这里是**纯 JVM** 逻辑(不碰 `Build`/`Context`),所以能在单测里逐字段验证;
* Android 侧只负责把 `Build.*` 填进 [Whoami.DeviceFacts]。
*/
public final class WhoamiTest {
/** 抓包里的设备事实(SM-G9730 / Samsung / Android 12)。 */
private static Whoami.DeviceFacts harFacts() {
return new Whoami.DeviceFacts(
"SM-G9730", "samsung", "beyond1qltezc", "beyond1q", "msmnile", "qcom",
"samsung/beyond1qltezc/beyond1q:12/SP1A.210812.016/G9730ZCU8HZE1:user/release-keys",
"G9730ZCU8HZE1", "12", 31,
"ae60e9547958", "ae60e9547958",
Whoami.DEFAULT_PKN, Whoami.DEFAULT_KTV_ID);
}
private static long harTs() {
return 1791702330016L;
}
/** 24 个字段一个不少、顺序与抓包一致。 */
@Test
public void queryHasAllHarFieldsInOrder() {
Map<String, String> q = Whoami.buildQuery(harFacts(), harTs(), true);
List<String> keys = new ArrayList<>(q.keySet());
assertEquals(Arrays.asList(
"_src", "_src_at", "_m", "_mac", "_vc", "_vn", "_pkn", "_player", "_boot", "_ts",
"_api", "ktv_id", "androidid", "_manuf", "_cid", "d_name", "d_device", "d_board",
"d_hardware", "d_fingerprint", "d_incremental", "d_release", "d_utc", "d_user"), keys);
assertEquals(24, q.size());
assertEquals(Whoami.PARAM_ORDER, keys);
for (String k : Whoami.PARAM_ORDER) q.remove(k);
assertTrue("PARAM_ORDER 与 buildQuery 必须同源", q.isEmpty());
}
/** 各字段取值:抓包固定值、身份一致性、时间戳。 */
@Test
public void queryValuesMatchHar() {
Map<String, String> d = Whoami.buildQuery(harFacts(), harTs(), true);
assertEquals("600007", d.get("_src"));
assertEquals("600001", d.get("_src_at"));
assertEquals("SM-G9730", d.get("_m"));
assertEquals("ae60e9547958", d.get("_mac"));
assertEquals("4", d.get("_vc"));
assertEquals("4.1.3.03281430", d.get("_vn"));
assertEquals("com.origjoy.local.ktv", d.get("_pkn"));
assertEquals("0", d.get("_player"));
assertEquals("1", d.get("_boot"));
assertEquals("1791702330016", d.get("_ts"));
assertEquals("31", d.get("_api"));
assertEquals("10006789", d.get("ktv_id"));
assertEquals("ae60e9547958", d.get("androidid"));
assertEquals("samsung", d.get("_manuf"));
assertEquals("A_ae60e9547958", d.get("_cid")); // 抓包:_cid = A_<mac>
assertEquals("beyond1qltezc", d.get("d_name"));
assertEquals("beyond1q", d.get("d_device"));
assertEquals("msmnile", d.get("d_board"));
assertEquals("qcom", d.get("d_hardware"));
assertEquals("G9730ZCU8HZE1", d.get("d_incremental"));
assertEquals("12", d.get("d_release"));
assertEquals("dpi", d.get("d_user"));
assertEquals(d.get("_ts"), d.get("d_utc")); // 抓包:_ts 与 d_utc 同值
assertEquals(harFacts().getFingerprint(), d.get("d_fingerprint"));
}
/** 周期上报的 `_boot=0`(启动那次才是 1)。 */
@Test
public void bootFlagOnlyOnFirstReport() {
assertEquals("1", Whoami.buildQuery(harFacts(), harTs(), true).get("_boot"));
assertEquals("0", Whoami.buildQuery(harFacts(), harTs(), false).get("_boot"));
}
/** `_pkn` / `ktv_id` 可配置(服务器侧同名配置项一致)。 */
@Test
public void pknAndKtvIdAreConfigurable() {
Whoami.DeviceFacts f = new Whoami.DeviceFacts(
"M", "MF", "P", "D", "B", "H", "FP", "INC", "12", 31, "mac", "mac",
"com.example.custom", "42");
Map<String, String> d = Whoami.buildQuery(f, 0L, false);
assertEquals("com.example.custom", d.get("_pkn"));
assertEquals("42", d.get("ktv_id"));
}
/** URL 必须带全部 24 个参数、且百分号编码与抓包一致(`/`→%2F、`:`→%3A)。 */
@Test
public void urlIsFullyEncoded() throws Exception {
// Java 侧没有 Kotlin 默认参数:显式传默认 URL(与 App 实际调用一致)
String url = Whoami.buildUrl(harFacts(), harTs(), true, Whoami.DEFAULT_URL);
assertTrue(url, url.startsWith(Whoami.DEFAULT_URL + "?"));
String query = url.substring(url.indexOf('?') + 1);
assertEquals("24 个参数 → 23 个 &", 23, countChar(query, '&'));
assertTrue("fingerprint 的 / 必须编码: " + query, query.contains("%2F"));
assertTrue("fingerprint 的 : 必须编码: " + query, query.contains("%3A"));
assertTrue(query.contains("_pkn=com.origjoy.local.ktv"));
assertTrue(query.contains("_boot=1"));
// 解码后应能还原全部字段
Map<String, String> decoded = new LinkedHashMap<>();
for (String kv : query.split("&")) {
int i = kv.indexOf('=');
decoded.put(kv.substring(0, i), java.net.URLDecoder.decode(kv.substring(i + 1), "UTF-8"));
}
assertEquals(24, decoded.size());
assertEquals(harFacts().getFingerprint(), decoded.get("d_fingerprint"));
}
/** UA 形状与抓包一致(Dalvik + Android 版本 + 型号 + Build)。 */
@Test
public void userAgentMatchesDalvikShape() {
String ua = Whoami.userAgent(harFacts());
assertEquals("Dalvik/2.1.0 (Linux; U; Android 12; SM-G9730 Build/G9730ZCU8HZE1)", ua);
}
/** 响应解析:拿到 `next_interval`;拿不到返回 null(调用方用默认 3600)。 */
@Test
public void parseNextInterval() {
assertEquals(Integer.valueOf(3600),
Whoami.parseNextInterval("{\"msg\":\"\",\"code\":200,\"next_interval\":3600}"));
assertEquals(Integer.valueOf(7),
Whoami.parseNextInterval("{\"next_interval\" : 7}"));
assertNull(Whoami.parseNextInterval("{\"code\":200}"));
assertNull(Whoami.parseNextInterval(""));
assertNull(Whoami.parseNextInterval(null));
assertNull(Whoami.parseNextInterval("<html>502</html>"));
}
/** 间隔统一夹到 [60s, 24h],缺省 3600s。 */
@Test
public void clampInterval() {
assertEquals(60, Whoami.clampInterval(7));
assertEquals(60, Whoami.clampInterval(Whoami.MIN_INTERVAL_SEC));
assertEquals(3600, Whoami.clampInterval(null));
assertEquals(24 * 3600, Whoami.clampInterval(100000000));
assertNotNull(Whoami.DEFAULT_URL);
}
// ── 小工具(避免在测试里引第三方依赖) ──
@SuppressWarnings("deprecation")
private static int countChar(String s, char c) {
int n = 0;
for (int i = 0; i < s.length(); i++) if (s.charAt(i) == c) n++;
return n;
}
}