V1.4.10: 修复广告过滤失效(gz.ac16.vip 广告被当成歌曲下载播放)

现场事故(新设备 SM-S9180 / Android 16,WebView 正常 → 走 JS 取址):
  277  JS [KtvApi] URL OK: 7303235 ls=1 via http://w.w345.my
  278  点歌编号=7303235 标题=情歌(HD) 请求源=http://w.w345.my 状态=成功 urlHost=gz.ac16.vip
  279  PLAY id=7303235 title=情歌(HD) src=local
即 gz.ac16.vip 的广告视频被当成歌曲下载、缓存并播放。

三处漏洞叠加:
1. ktv_api.js 的 isDemoUrl 不认识广告域名;且"ts 文件名与编号比对"用的是
   /\/(\d+)\.ts/,只匹配纯数字名 —— my_ad_video.ts 这类非数字名字直接放行;
2. SongOkDownloadManager.buildDownloadUrl 的**成功分支完全没有广告校验**
   (AdVideoFilter.isAdVideo 全项目只在"缓存地址"与"原生解析"两处被调用);
3. SongApiClient.fetchFromDb 只做 isLikelyDemoUrl(纯 IP / r2.dev),挡不住广告域名。
且**内容校验挡不住广告**: 该广告通过 SongFileValidator(≥6MB/≥60s/TS 包完整),
被 isDownloaded() 一直复用,广告会反复播。

修复(单一闸门,全链路把关):
- AdVideoFilter 新增 isTrustedSongUrl(url, musicno):非 http(s)、命中广告特征
  (路径 /ad_files/、my_ad_video、**域名 *.ac16.vip**)、或 URL 里 .ts 文件名不等于
  点歌编号 → 一律不可信。真源恒为 /<musicno>.ts(已实测 txog.ktvsky.com 与
  download.origjoy.com 两个 CDN 域共 6 首全部相符)。
- 该闸门接入全部出入口: JS isDemoUrl(同步加固 + KTV_BRIDGE_API 8→9)、
  SongUrlResolver.isValidSongUrl、SongApiClient.fetchFromDb、
  以及最终兜底 SongOkDownloadManager.buildDownloadUrl(成功/缓存两条分支)。
- 判定不可信时**同时删除已落盘的歌曲文件**(purgeCachedSongFile),
  否则 isDownloaded() 会一直复用那个广告文件。
- 新增 SongOkDownloadManager.purgeLegacySongCacheOnce(): 升级后一次性清理历史遗留的
  可疑歌曲缓存(marker: filesDir/song_cache_purged_v1)。只是让歌曲重新下载,不动用户数据。
- 新增回归测试 AdVideoFilterTest(6 例,用日志里的真实 URL 形态,含两个真源 CDN 域);
  该测试当场抓到本修复自身的一个 bug:正则把扩展名也捕获进去导致真源被误杀,已修正。

版本 1.4.9(29) 到 1.4.10(30);签名证书不变。
This commit is contained in:
zijunchiang
2026-09-26 21:17:04 +08:00
parent b3b17210f1
commit 669766ba60
10 changed files with 234 additions and 23 deletions
+19 -1
View File
@@ -160,7 +160,7 @@ $BT/apksigner verify 目标名.apk
### 版本号
- `app/build.gradle`:`versionName "1.4.9"` / `versionCode 29`(18~21 被本机临时诊断包占用过)
- `app/build.gradle`:`versionName "1.4.10"` / `versionCode 30`(18~21 被本机临时诊断包占用过)
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
@@ -273,6 +273,24 @@ $BT/apksigner verify 目标名.apk
`NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch`;
桥侧另有 `JS-BRIDGE ...`(ready / KtvApi 未导出 / WebView 初始化失败 / 15s 未就绪)。
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
- **⚠️ 广告/错号地址只有一道闸门:`AdVideoFilter.isTrustedSongUrl(url, musicno)`**
(2026-09-26 事故:新设备点歌 7303235 拿到 `gz.ac16.vip` 的**广告视频并被当成歌曲下载播放**)。
约定:
1. **任何来源**的播放地址都必须过这道闸门 —— JS 取址(`ktv_api.js` 的 `isDemoUrl`)、
原生兜底(`SongUrlResolver.isValidSongUrl`)、DB 回退(`SongApiClient.fetchFromDb`),
以及**最终兜底** `SongOkDownloadManager.buildDownloadUrl` 的成功分支。
只加在某一层必然漏:这次就是 JS 层的规则不认广告域名、且 Kotlin 的成功分支**完全没做校验**;
2. 判定三条:非 http(s);命中广告特征(路径 `/ad_files/`、`my_ad_video`、**域名 `*.ac16.vip`**);
**URL 里的 `.ts` 文件名必须等于点歌编号**(真源恒为 `/<musicno>.ts`,已实测
`txog.ktvsky.com` / `download.origjoy.com` 两个 CDN 域)。旧规则只比对"纯数字文件名",
`my_ad_video.ts` 这类非数字名字直接漏过;
3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整),
所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件**
(`purgeCachedSongFile`),否则 `isDownloaded()` 会一直复用那个广告文件,广告反复播;
4. 历史遗留由 `SongOkDownloadManager.purgeLegacySongCacheOnce()` 在升级后清一次
(marker:`filesDir/song_cache_purged_v1`)。清掉只是让歌曲重新下载,不影响用户数据;
5. 回归测试 `app/src/test/java/com/local/ktv/AdVideoFilterTest.java` 用日志里的**真实 URL 形态**
锁住行为(含两个真源 CDN 域),改这块逻辑必须先让它通过。
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
- **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
- **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
versionCode 29
versionName "1.4.9"
versionCode 30
versionName "1.4.10"
ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
+11 -5
View File
@@ -12,7 +12,7 @@
(function(global) {
'use strict';
// KTV_BRIDGE_API: 8 (Android 缓存校验标记,请勿删除)
// KTV_BRIDGE_API: 9 (Android 缓存校验标记,请勿删除)
// ─── 配置 ──────────────────────────────────────────
const CONFIG = {
@@ -138,13 +138,19 @@
function isDemoUrl(url, musicno) {
var s = String(url || '');
if (/(?:wb66|demo)/i.test(s)) 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;
// R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源
if (/pub-[a-z0-9]+\.r2\.dev\/ss\//i.test(s)) return true;
// ts 文件名是纯数字, 若与请求的 musicno 不一致则视为广告/测试资源。
// 原版真源 URL 的 ts 文件名总是等于 musicno (如 /7120742.ts)。
// ts 文件名必须等于 musicno。原版真源 URL 恒为 /<musicno>.ts(已实测两个 CDN 域
// txog.ktvsky.com / download.origjoy.com 的 6 首歌全部如此)。
// ⚠️ 旧规则只比对"纯数字文件名"(/\/(\d+)\.ts/),像 my_ad_video.ts 这种非数字名字
// 会直接漏过 —— 这正是广告漏放的直接原因之一。
if (musicno) {
var m = s.match(/\/(\d+)\.ts(?:\?|$)/);
var m = s.match(/\/([^\/?]+)\.ts(?:\?|$)/i);
if (m && m[1] !== String(musicno)) return true;
}
return false;
@@ -435,7 +441,7 @@
// ─── 导出 ──────────────────────────────────────────
global.KtvApi = KtvApi;
global.KtvApiConfig = CONFIG;
global.KtvBridgeApiVersion = 8;
global.KtvBridgeApiVersion = 9;
if (typeof module !== 'undefined' && module.exports) {
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
@@ -21,6 +21,19 @@ object AdVideoFilter {
"my_ad_video",
)
/**
* 广告视频专用域名(命中即判定为广告,含其子域)。
*
* 2026-09-26 现场事故:新设备(WebView 正常 → 走 JS 取址)点歌 7303235 拿到了
* `gz.ac16.vip` 的广告视频,而且它**通过了内容校验**(≥6MB / ≥60s / TS 包完整),
* 于是被当歌曲缓存并播放。仅靠路径片段(`/ad_files/`、`my_ad_video`)不足以拦住 ——
* 广告方一改文件名就漏,因此必须**按域名**兜底。
*/
private val AD_HOSTS = listOf(
"gz.ac16.vip",
"ac16.vip",
)
/**
* 替换地址。为 null 时表示「屏蔽」(返回 null,由 DB 真实歌曲地址兜底);
* 设置后,命中广告时用该地址替换原广告。
@@ -29,14 +42,43 @@ object AdVideoFilter {
@JvmField
var replacementUrl: String? = null
/** 判断是否为广告视频地址。 */
/** 判断是否为广告视频地址(路径特征 + 域名)。 */
@JvmStatic
fun isAdVideo(url: String?): Boolean {
if (url.isNullOrEmpty()) return false
val lower = url.lowercase()
return AD_MARKERS.any { lower.contains(it) }
if (AD_MARKERS.any { lower.contains(it) }) return true
val host = hostOf(url) ?: return false
return AD_HOSTS.any { host == it || host.endsWith(".$it") }
}
/**
* 取址链路的**统一可信校验**(所有来源的地址都必须过这一关)。
*
* 判定为不可信的情况:
* 1. 非 http(s);
* 2. 命中广告特征([isAdVideo]);
* 3. **URL 里的 `.ts` 文件名与点歌编号不一致** —— 真源 URL 的文件名恒等于点歌编号
* (见 `ktv_api.js` 的说明),而广告文件常叫 `my_ad_video.ts`。
* 旧规则只处理"纯数字且与编号不同"(`/\/(\d+)\.ts/`),非数字文件名直接放行,
* 这正是本次广告漏放的直接原因。
*/
@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)
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()
/** 取 `.ts` 的**文件名主体**(不含扩展名),供与点歌编号比对。 */
private val TS_BASENAME = Regex("/([^/?]+)\\.ts(?:\\?|$)", RegexOption.IGNORE_CASE)
/**
* 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换);
* 非广告地址原样返回。
@@ -335,7 +335,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: 8")
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 9")
}
}.getOrDefault(false)
@@ -361,7 +361,7 @@ class KtvJsBridge(context: Context) {
val script = String(bytes, StandardCharsets.UTF_8)
require(
script.length > 1000 && script.contains("KtvApi") &&
script.contains("KTV_BRIDGE_API: 8"),
script.contains("KTV_BRIDGE_API: 9"),
) { "Invalid or incompatible JS file" }
tempFile.writeText(script, Charsets.UTF_8)
if (!tempFile.renameTo(cacheFile)) {
@@ -529,6 +529,10 @@ class MainActivity : AppCompatActivity() {
initializeDatabase()
startRemoteServer()
updateMobileQrOverlay()
// 一次性清理历史遗留的可疑歌曲缓存:1.4.9 及以前 JS 取址的地址没做广告校验,
// 广告视频会被当成歌曲落盘,且能通过内容校验被 isDownloaded() 一直复用(广告反复播)。
// 清理只是让歌曲重新下载,不影响用户数据;之后由「取址即校验」保证不再产生。
io.execute { SongOkDownloadManager.purgeLegacySongCacheOnce(applicationContext) }
main.postDelayed(remoteNetworkMonitor, 5000)
// 网络状态监测: 断网提示 + 恢复后重建歌曲源会话/重试下载。
KtvNetworkMonitor.start(applicationContext) { online, initial ->
@@ -112,8 +112,11 @@ object SongApiClient {
if (db.open()) {
val url = db.getCloudUrl(musicno)
db.close()
// DB 中的 cloud_url 可能也是 demo 地址, 做 URL 基本校验
if (url != null && !isLikelyDemoUrl(url)) return url
// DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验
// (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。
if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) {
return url
}
}
} catch (_: Exception) {}
return null
@@ -23,6 +23,9 @@ object SongOkDownloadManager {
private const val MAX_RETRY_COUNT = 3
private const val STALL_TIMEOUT_MS = 25_000L
/** 曲库视频目录下会被当作"已下载歌曲"的文件后缀(用于一次性清理历史广告缓存)。 */
private val MEDIA_EXTENSIONS = setOf("ts", "ls", "mp4", "mkv")
interface DownloadCallback {
fun onDownloadStart(song: Song)
fun onDownloadProgress(song: Song, progress: Int)
@@ -73,6 +76,41 @@ object SongOkDownloadManager {
return downloaded
}
/**
* 一次性清理历史遗留的歌曲缓存(广告漏放期间的产物)。
*
* 背景(2026-09-26 事故):1.4.9 及以前,JS 取址返回的地址在 `buildDownloadUrl` 的成功分支里
* **完全没有过广告校验**,广告视频(如 `gz.ac16.vip`)会被当成歌曲下载并落盘;它又能通过
* [SongFileValidator] 的内容校验(≥6MB / ≥60s / TS 包完整),因此**靠内容无法识别**,
* 而且 [isDownloaded] 会直接复用该文件、连取址都不会再发生 —— 广告会一直播下去。
*
* 这类遗留文件只能在升级时清一次;此后由「取址即校验」(
* [AdVideoFilter.isTrustedSongUrl])保证不再产生。清掉只是让歌曲重新下载,不影响任何用户数据。
*
* @return 删除的文件数(已清理过则返回 0)
*/
@JvmStatic
fun purgeLegacySongCacheOnce(context: android.content.Context): Int {
val marker = File(context.filesDir, "song_cache_purged_v1")
if (marker.exists()) return 0
var removed = 0
runCatching {
val dir = File("${MuseDatabase.VIDEO_ROOT}/${MuseDatabase.CLOUD_SONG_DIR}")
dir.listFiles()?.forEach { file ->
if (file.isFile && file.extension.lowercase() in MEDIA_EXTENSIONS) {
SongFileValidator.forget(file)
if (file.delete()) removed++
}
}
marker.writeText("purged=${System.currentTimeMillis()} removed=$removed", Charsets.UTF_8)
}.onFailure { Log.w(TAG, "purge legacy song cache failed: ${it.message}") }
// 首次启动才会真正有动作,之后 marker 存在会直接返回 0,不产生噪声日志。
if (removed > 0 || !marker.exists()) {
MiliLog.warn("SONG-AD 一次性清理历史歌曲缓存 removed=$removed(旧版本可能把广告缓存成歌曲)")
}
return removed
}
@JvmStatic
fun isDownloading(song: Song): Boolean {
val key = songKey(song)
@@ -173,33 +211,66 @@ object SongOkDownloadManager {
}
private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String {
val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id
if (!forceRefresh) {
song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached ->
if (!AdVideoFilter.isAdVideo(cached)) return cached
// 缓存的下载地址是广告视频,清掉并重新解析(广告会被桥接层屏蔽/替换)。
Log.w(TAG, "丢弃广告缓存地址: ${song.title}")
if (AdVideoFilter.isTrustedSongUrl(cached, musicNo)) return cached
// 缓存的地址不可信(广告 / 文件名与点歌编号不符):除了清地址,还要**删掉已落盘的
// 同名文件** —— 广告视频能通过内容校验(≥6MB / ≥60s / TS 包完整),
// 一旦被缓存成歌曲,isDownloaded() 会直接复用、连取址都不会再发生。
Log.w(TAG, "丢弃不可信缓存地址: ${song.title} -> $cached")
MiliLog.warn("SONG-AD 丢弃不可信缓存地址 编号=$musicNo url=$cached")
song.downloadUrl = null
purgeCachedSongFile(song, "untrusted-cached-url")
}
}
val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id
return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url ->
// 请求源 = 本次点歌实际命中的源(JS 上报); 取不到时留空。
val srcHost = SongApiClient.lastSongSourceHost()
if (url.isNotEmpty()) {
// ⚠️ 广告漏放的**最终兜底防线**:不管地址来自 JS 取址、原生兜底还是 DB 回退,
// 都必须过 AdVideoFilter.isTrustedSongUrl(2026-09-26 事故:JS 路径返回
// gz.ac16.vip 的广告视频,因为这里的成功分支完全没做广告校验而被当成歌曲下载播放)。
if (url.isNotEmpty() && AdVideoFilter.isTrustedSongUrl(url, musicNo)) {
song.downloadUrl = url
val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown"
MiliLog.info(
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=成功 urlHost=$cdnHost",
)
} else if (url.isNotEmpty()) {
Log.w(TAG, "Untrusted URL rejected for ${song.title}: $url")
MiliLog.error(
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 " +
"详情=地址不可信(广告或文件名不符) url=$url",
)
purgeCachedSongFile(song, "untrusted-resolved-url")
""
} else {
Log.w(TAG, "Unable to get download URL: ${song.title}")
MiliLog.error(
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 详情=无法生成下载地址",
)
""
}
}
}
/** 删除某首歌已落盘的文件(发现地址不可信/命中广告时调用,让下次点歌重新取址下载)。 */
private fun purgeCachedSongFile(song: Song, reason: String) {
runCatching {
val file = getLocalFile(song)
if (file.exists()) {
val sizeMb = file.length() / 1024 / 1024
SongFileValidator.forget(file)
val deleted = file.delete()
MiliLog.warn(
"SONG-AD 清除可疑歌曲缓存 编号=${song.id} 原因=$reason " +
"大小=${sizeMb}MB 已删除=$deleted",
)
}
song.path = null
}.onFailure { Log.w(TAG, "purge cached file failed: ${it.message}") }
}
private fun startDownloadTask(song: Song, url: String, callback: DownloadCallback?, attempt: Int) {
val key = songKey(song)
val target = getLocalFile(song)
@@ -202,15 +202,16 @@ object SongUrlResolver {
}
}
/** 镜像 ktv_api.js 的 `isDemoUrl`:广告视频 / 纯 IP / R2 测试桶 / ts 编号与请求不一致。 */
/**
* 原生路径的可信校验:先过 `AdVideoFilter.isTrustedSongUrl`(广告域名/路径 + `.ts` 文件名
* 必须等于点歌编号),再补上 JS `isDemoUrl` 里那几条(wb66/demo、纯 IP、R2 测试桶)。
*/
private fun isValidSongUrl(url: String, musicno: String): Boolean {
if (AdVideoFilter.isAdVideo(url)) return false
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
val matched = TS_NAME.find(url)?.groupValues?.getOrNull(1)
if (matched != null && matched != musicno) return false
return true
}
@@ -0,0 +1,66 @@
package com.local.ktv;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* 广告地址拦截的回归测试。
*
* 对应 2026-09-26 现场事故:新设备(WebView 正常 → 走 JS 取址)点歌 7303235(情歌 HD)
* 拿到 `gz.ac16.vip` 的广告视频,被当成歌曲下载并播放。当时的漏洞有三处:
* 1. JS 的 isDemoUrl 不认识该广告域名,且只比对"纯数字文件名",非数字名字直接放行;
* 2. Kotlin 的 buildDownloadUrl 成功分支**完全没做广告校验**;
* 3. DB 回退路径只做 isLikelyDemoUrl,也挡不住广告域名。
* 这里用日志里的**真实 URL 形态**锁住入参校验行为。
*/
public final class AdVideoFilterTest {
/** 真源地址:两个 CDN 域实测文件名都严格等于点歌编号,必须放行。 */
@Test
public void acceptsRealSourceUrls() {
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://txog.ktvsky.com/264/lvbr/480/E/ts/47.1/202209220333/480p/7303235.ts?token=526e505e",
"7303235"));
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://download.origjoy.com/264/lvbr/480/E/ts/39.6/202309070206/480p/7445418.ts?token=abc",
"7445418"));
}
/** 广告域名:即使文件名伪装成正确的点歌编号,也必须拦下。 */
@Test
public void rejectsAdHostEvenWithMatchingName() {
assertFalse(AdVideoFilter.isTrustedSongUrl("http://gz.ac16.vip/ad/7303235.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("http://cdn.ac16.vip/x/7303235.ts", "7303235"));
}
/** 广告文件名的经典形态:非数字名字,旧规则的正则匹配不到。 */
@Test
public void rejectsAdFileName() {
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/ad_files/my_ad_video.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/my_ad_video.ts", "7303235"));
}
/** .ts 文件名与点歌编号不符(即使不命中任何广告域名)也视为不可信。 */
@Test
public void rejectsMismatchedFileName() {
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/9999999.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/promo.ts", "7303235"));
}
/** 非 http(s) 一律不可信。 */
@Test
public void rejectsNonHttpSchemes() {
assertFalse(AdVideoFilter.isTrustedSongUrl("file:///sdcard/x/7303235.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("ftp://example.com/7303235.ts", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl("", "7303235"));
assertFalse(AdVideoFilter.isTrustedSongUrl(null, "7303235"));
}
/** 没有 .ts 文件名时不做文件名比对(避免误杀非 ts 的真源)。 */
@Test
public void allowsNonTsUrlWithoutAdMarkers() {
assertTrue(AdVideoFilter.isTrustedSongUrl("http://cdn.example.com/path/stream?token=abc", "7303235"));
}
}