合并自建歌曲源支线(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:
+2
-2
@@ -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,否则
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(?:\\?|$)")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user