From f012214a98b3f3c689596953072e9ea251609839 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sat, 29 Aug 2026 22:27:06 +0800
Subject: [PATCH 01/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=B9=BF?=
=?UTF-8?q?=E5=91=8A=E5=8C=BA=E9=97=B4=E5=8F=8D=E9=A6=88=E7=9A=84=E8=AF=81?=
=?UTF-8?q?=E6=8D=AE=E5=A5=91=E7=BA=A6=E4=B8=8E=E5=88=87=E7=89=87=E6=98=A0?=
=?UTF-8?q?=E5=B0=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第一个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 6.1、7.1 节。
- AdIntervalMapper:播放时间↔切片下标映射(durations 前缀和,半开区间
相交),以及短按场景的起点推断(DISCONTINUITY → 跨域切换 → 音频候选
→ 固定窗口逐级降级)。纯计算,无新增网络请求。
- AdIntervalEvidence / SegmentFact / AudioIntervalFact / SpeechIntervalFact:
区间证据快照契约,只保留 host 与去参数 path,沿用 AdDetectionRequest
的去敏原则。
- StartOrigin:起点来源枚举,携带证据权重供后续仲裁使用。
尚未接线到播放器,无行为变化。
Verification: testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ad.feedback.* : 11 tests, 0 failures, 0 errors
Task-Guard: AF1-evidence
---
.../tv/ad/feedback/AdIntervalEvidence.java | 81 ++++++++++++
.../tv/ad/feedback/AdIntervalMapper.java | 119 ++++++++++++++++++
.../tv/ad/feedback/AudioIntervalFact.java | 25 ++++
.../android/tv/ad/feedback/SegmentFact.java | 37 ++++++
.../tv/ad/feedback/SpeechIntervalFact.java | 25 ++++
.../android/tv/ad/feedback/StartOrigin.java | 31 +++++
.../tv/ad/feedback/AdIntervalMapperTest.java | 118 +++++++++++++++++
7 files changed, 436 insertions(+)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapperTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java
new file mode 100644
index 0000000000..d73ff694aa
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalEvidence.java
@@ -0,0 +1,81 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.List;
+
+/**
+ * 用户反馈区间的证据快照。一次采集,之后所有分类通道只读。
+ *
+ *
去敏原则沿用 {@code AdDetectionRequest}:只保留 host 与去参数 path,
+ * 不含完整 URL、query、token、Cookie。见设计文档第 6.1、10 节。
+ *
+ * @param startMs 区间起点,毫秒
+ * @param endMs 区间终点,毫秒
+ * @param startOrigin 起点来源
+ * @param siteKey 站点标识
+ * @param siteName 站点名称
+ * @param vodName 剧名
+ * @param flagName 线路名
+ * @param episodeName 集名
+ * @param playlistHost 主 playlist 的 host
+ * @param urlPath 播放地址去参数 path
+ * @param hls 是否 HLS
+ * @param inside 区间内切片
+ * @param outside 区间外切片,作为对照组
+ * @param boundedByDiscontinuity 区间首尾是否都贴着 DISCONTINUITY 边界
+ * @param crossDomain 区间内切片是否整体跨域
+ * @param alreadyRemovedByStructuredRuleIds 已被结构化规则删除的规则 id
+ * @param legacyHeuristicActive 旧启发式引擎当次是否生效
+ * @param matchedExistingHosts 命中现有黑名单的 host
+ * @param audio 音频指纹通道事实
+ * @param speech 语音通道事实
+ */
+public record AdIntervalEvidence(
+ long startMs, long endMs, StartOrigin startOrigin,
+ String siteKey, String siteName, String vodName,
+ String flagName, String episodeName,
+ String playlistHost, String urlPath, boolean hls,
+ List inside, List outside,
+ boolean boundedByDiscontinuity, boolean crossDomain,
+ List alreadyRemovedByStructuredRuleIds,
+ boolean legacyHeuristicActive,
+ List matchedExistingHosts,
+ AudioIntervalFact audio, SpeechIntervalFact speech) {
+
+ public AdIntervalEvidence {
+ if (startMs < 0) throw new IllegalArgumentException("startMs must not be negative");
+ if (endMs <= startMs) throw new IllegalArgumentException("endMs must be greater than startMs");
+ startOrigin = startOrigin == null ? StartOrigin.FALLBACK_WINDOW : startOrigin;
+ siteKey = siteKey == null ? "" : siteKey;
+ siteName = siteName == null ? "" : siteName;
+ vodName = vodName == null ? "" : vodName;
+ flagName = flagName == null ? "" : flagName;
+ episodeName = episodeName == null ? "" : episodeName;
+ playlistHost = playlistHost == null ? "" : playlistHost;
+ urlPath = urlPath == null ? "" : urlPath;
+ inside = inside == null ? List.of() : List.copyOf(inside);
+ outside = outside == null ? List.of() : List.copyOf(outside);
+ alreadyRemovedByStructuredRuleIds = alreadyRemovedByStructuredRuleIds == null
+ ? List.of() : List.copyOf(alreadyRemovedByStructuredRuleIds);
+ matchedExistingHosts = matchedExistingHosts == null ? List.of() : List.copyOf(matchedExistingHosts);
+ audio = audio == null ? AudioIntervalFact.unavailable() : audio;
+ speech = speech == null ? SpeechIntervalFact.unavailable() : speech;
+ }
+
+ /** 区间时长,毫秒。 */
+ public long durationMs() {
+ return endMs - startMs;
+ }
+
+ /** 区间内是否有可用的切片证据。无切片时 HLS 通道必须弃权。 */
+ public boolean hasSegmentEvidence() {
+ return hls && !inside.isEmpty();
+ }
+
+ /**
+ * 用户报告的区间是否已被结构化规则整体处理掉。
+ * 为真时说明用户看到的广告来自别处,不应再新增 HLS 规则。
+ */
+ public boolean handledByStructuredRule() {
+ return !alreadyRemovedByStructuredRuleIds.isEmpty();
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java
new file mode 100644
index 0000000000..6d047d57ce
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapper.java
@@ -0,0 +1,119 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import com.fongmi.android.tv.bean.M3u8Evidence;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 播放时间 ↔ HLS 切片下标的映射,以及短按场景的起点推断。
+ *
+ * 纯计算,不做网络请求:{@code M3u8Parser.parse()} 已经产出全部所需输入。
+ * 见设计文档第 4.2、7.1 节。
+ */
+public final class AdIntervalMapper {
+
+ /** 短按回溯窗口默认值,见设计文档待确认决策 1。 */
+ public static final long DEFAULT_FALLBACK_WINDOW_MS = 90_000L;
+
+ private AdIntervalMapper() {
+ }
+
+ /**
+ * 区间起点推断结果。
+ *
+ * @param startMs 推断出的起点
+ * @param origin 起点来源
+ */
+ public record InferredStart(long startMs, StartOrigin origin) {
+ }
+
+ /**
+ * 找出与 {@code [startMs, endMs)} 有交集的切片下标。
+ *
+ *
切片 {@code i} 覆盖 {@code [cum[i], cum[i+1])},判据是半开区间相交:
+ * {@code cum[i+1] > startSec && cum[i] < endSec}。恰好贴边不算相交。
+ */
+ public static List insideIndices(List durations, long startMs, long endMs) {
+ List indices = new ArrayList<>();
+ if (durations == null || durations.isEmpty() || endMs <= startMs) return indices;
+ double startSec = startMs / 1000d;
+ double endSec = endMs / 1000d;
+ double cum = 0;
+ for (int i = 0; i < durations.size(); i++) {
+ Float value = durations.get(i);
+ double duration = value == null ? 0 : value;
+ double next = cum + duration;
+ if (next > startSec && cum < endSec) indices.add(i);
+ cum = next;
+ }
+ return indices;
+ }
+
+ /** 切片 {@code index} 的起始播放位置,毫秒。 */
+ public static long segmentStartMs(List durations, int index) {
+ if (durations == null || index <= 0) return 0L;
+ double cum = 0;
+ for (int i = 0; i < index && i < durations.size(); i++) {
+ Float value = durations.get(i);
+ if (value != null) cum += value;
+ }
+ return Math.round(cum * 1000d);
+ }
+
+ /**
+ * 短按场景下由终点回溯推断起点。按可靠性逐级尝试:
+ * DISCONTINUITY 边界 → 跨域切换点 → 音频候选 → 固定窗口。
+ *
+ * @param evidence playlist 证据,可为 null
+ * @param endMs 用户点击时的播放位置
+ * @param audioCandidateStartMs 音频/语音通道已有候选的起点,无则传负数
+ * @param fallbackWindowMs 兜底回溯窗口
+ */
+ public static InferredStart inferStart(M3u8Evidence evidence, long endMs,
+ long audioCandidateStartMs, long fallbackWindowMs) {
+ if (endMs <= 0) return new InferredStart(0L, StartOrigin.FALLBACK_WINDOW);
+
+ if (evidence != null && !evidence.isEmpty()) {
+ List durations = evidence.getDurations();
+
+ long discontinuity = latestBoundaryBefore(durations, evidence.getDiscontinuities(), endMs);
+ if (discontinuity >= 0) return new InferredStart(discontinuity, StartOrigin.DISCONTINUITY);
+
+ long crossDomain = latestSwitchBefore(durations, evidence.getDomainSwitches(), endMs);
+ if (crossDomain >= 0) return new InferredStart(crossDomain, StartOrigin.CROSS_DOMAIN);
+ }
+
+ if (audioCandidateStartMs >= 0 && audioCandidateStartMs < endMs) {
+ return new InferredStart(audioCandidateStartMs, StartOrigin.AUDIO_CANDIDATE);
+ }
+
+ long window = fallbackWindowMs > 0 ? fallbackWindowMs : DEFAULT_FALLBACK_WINDOW_MS;
+ return new InferredStart(Math.max(0L, endMs - window), StartOrigin.FALLBACK_WINDOW);
+ }
+
+ /** 最近一个位于 {@code endMs} 之前的断点边界起始位置,没有则返回 -1。 */
+ private static long latestBoundaryBefore(List durations, List discontinuities, long endMs) {
+ if (durations == null || discontinuities == null || discontinuities.isEmpty()) return -1L;
+ long best = -1L;
+ for (Integer index : discontinuities) {
+ if (index == null || index < 0 || index >= durations.size()) continue;
+ long position = segmentStartMs(durations, index);
+ if (position < endMs && position > best) best = position;
+ }
+ return best;
+ }
+
+ /** 最近一个位于 {@code endMs} 之前的跨域切换点起始位置,没有则返回 -1。 */
+ private static long latestSwitchBefore(List durations, List domainSwitches, long endMs) {
+ if (durations == null || domainSwitches == null || domainSwitches.isEmpty()) return -1L;
+ long best = -1L;
+ for (int i = 0; i < domainSwitches.size() && i < durations.size(); i++) {
+ Boolean switched = domainSwitches.get(i);
+ if (switched == null || !switched) continue;
+ long position = segmentStartMs(durations, i);
+ if (position < endMs && position > best) best = position;
+ }
+ return best;
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java
new file mode 100644
index 0000000000..23b1638e51
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AudioIntervalFact.java
@@ -0,0 +1,25 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.List;
+
+/**
+ * 音频指纹通道在区间内的既有匹配结果。Phase 1 只读,不采集新指纹。
+ *
+ * @param enabled 音频指纹功能是否已开启
+ * @param matchedRuleIds 区间内命中的规则 id
+ * @param captureReady 本次播放是否具备采集条件(Exo、非直播、可 seek、时长已知)
+ */
+public record AudioIntervalFact(boolean enabled, List matchedRuleIds, boolean captureReady) {
+
+ public AudioIntervalFact {
+ matchedRuleIds = matchedRuleIds == null ? List.of() : List.copyOf(matchedRuleIds);
+ }
+
+ public static AudioIntervalFact unavailable() {
+ return new AudioIntervalFact(false, List.of(), false);
+ }
+
+ public boolean hasMatch() {
+ return !matchedRuleIds.isEmpty();
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java
new file mode 100644
index 0000000000..5f01d5a813
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SegmentFact.java
@@ -0,0 +1,37 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * 单个 HLS 切片的去敏事实。只保留 host 与 path,不含 query 参数与 token。
+ *
+ * @param index 在 media playlist 中的下标,从 0 开始
+ * @param host 切片主机名,解析失败时为空串
+ * @param path 切片路径,已去掉 query
+ * @param durationSec #EXTINF 时长
+ * @param discontinuityBefore 该切片之前是否有 #EXT-X-DISCONTINUITY
+ */
+public record SegmentFact(int index, String host, String path,
+ double durationSec, boolean discontinuityBefore) {
+
+ public SegmentFact {
+ if (index < 0) throw new IllegalArgumentException("index must not be negative");
+ host = host == null ? "" : host;
+ path = path == null ? "" : path;
+ if (durationSec < 0) durationSec = 0;
+ }
+
+ /** host 是否以给定后缀结尾,用于与 playlist 域名和黑名单比对。 */
+ public boolean hostEndsWith(String suffix) {
+ if (host.isEmpty() || suffix == null || suffix.isEmpty()) return false;
+ String lower = host.toLowerCase(Locale.US);
+ String target = suffix.toLowerCase(Locale.US);
+ return lower.equals(target) || lower.endsWith("." + target);
+ }
+
+ /** host 是否与另一切片同域。 */
+ public boolean sameHost(SegmentFact other) {
+ return other != null && !host.isEmpty() && Objects.equals(host, other.host);
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java
new file mode 100644
index 0000000000..ef7b1aba86
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SpeechIntervalFact.java
@@ -0,0 +1,25 @@
+package com.fongmi.android.tv.ad.feedback;
+
+/**
+ * 语音关键词通道在区间内的既有命中情况。
+ *
+ * 刻意不记录命中的具体关键词:{@code SpeechAdSignalProvider} 的日志也遵守同一约定,
+ * 关键词可能包含用户自定义的敏感内容。
+ *
+ * @param enabled 语音去广是否已开启
+ * @param hitCount 区间内命中次数
+ */
+public record SpeechIntervalFact(boolean enabled, int hitCount) {
+
+ public SpeechIntervalFact {
+ if (hitCount < 0) hitCount = 0;
+ }
+
+ public static SpeechIntervalFact unavailable() {
+ return new SpeechIntervalFact(false, 0);
+ }
+
+ public boolean hasMatch() {
+ return hitCount > 0;
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java
new file mode 100644
index 0000000000..50a744b6ec
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/StartOrigin.java
@@ -0,0 +1,31 @@
+package com.fongmi.android.tv.ad.feedback;
+
+/**
+ * 区间起点的来源。短按只提供一个时间点,起点按可靠性逐级推断。
+ *
+ *
顺序即可靠性:{@link #USER_MARKED} 最强,{@link #FALLBACK_WINDOW} 最弱。
+ * 仲裁时用 {@link #weight()} 折算证据权重。
+ */
+public enum StartOrigin {
+ /** 用户在标记模式下显式框选。 */
+ USER_MARKED(1.0f),
+ /** 落在最近一个 #EXT-X-DISCONTINUITY 边界上。 */
+ DISCONTINUITY(0.95f),
+ /** 落在最近一次切片跨域切换点上。 */
+ CROSS_DOMAIN(0.85f),
+ /** 复用音频/语音通道已有候选的起点。 */
+ AUDIO_CANDIDATE(0.80f),
+ /** 无任何信号,按固定窗口回溯。 */
+ FALLBACK_WINDOW(0.55f);
+
+ private final float weight;
+
+ StartOrigin(float weight) {
+ this.weight = weight;
+ }
+
+ /** 证据权重,0..1。 */
+ public float weight() {
+ return weight;
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapperTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapperTest.java
new file mode 100644
index 0000000000..008a6fdb2a
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdIntervalMapperTest.java
@@ -0,0 +1,118 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.fongmi.android.tv.bean.M3u8Evidence;
+
+import org.junit.Test;
+
+import java.util.List;
+
+public class AdIntervalMapperTest {
+
+ private static final List UNIFORM = List.of(10f, 10f, 10f, 10f, 10f);
+
+ @Test
+ public void mapsIntervalToOverlappingSegments() {
+ // [15s, 35s) 与切片 1(10-20)、2(20-30)、3(30-40) 相交
+ assertEquals(List.of(1, 2, 3), AdIntervalMapper.insideIndices(UNIFORM, 15_000, 35_000));
+ }
+
+ @Test
+ public void intervalFlushWithSegmentBoundaryExcludesNeighbours() {
+ // [20s, 30s) 恰好是切片 2,贴边的 1 和 3 都不算相交
+ assertEquals(List.of(2), AdIntervalMapper.insideIndices(UNIFORM, 20_000, 30_000));
+ }
+
+ @Test
+ public void emptyOrInvalidInputYieldsNoSegments() {
+ assertTrue(AdIntervalMapper.insideIndices(null, 0, 10_000).isEmpty());
+ assertTrue(AdIntervalMapper.insideIndices(List.of(), 0, 10_000).isEmpty());
+ // 终点不晚于起点
+ assertTrue(AdIntervalMapper.insideIndices(UNIFORM, 20_000, 20_000).isEmpty());
+ assertTrue(AdIntervalMapper.insideIndices(UNIFORM, 30_000, 20_000).isEmpty());
+ }
+
+ @Test
+ public void toleratesNullDurationEntries() {
+ List withNull = java.util.Arrays.asList(10f, null, 10f);
+ // null 记为 0 时长,切片 1 退化为零宽度不与任何区间相交
+ assertEquals(List.of(2), AdIntervalMapper.insideIndices(withNull, 12_000, 18_000));
+ }
+
+ @Test
+ public void segmentStartAccumulatesPrecedingDurations() {
+ assertEquals(0L, AdIntervalMapper.segmentStartMs(UNIFORM, 0));
+ assertEquals(30_000L, AdIntervalMapper.segmentStartMs(UNIFORM, 3));
+ // 下标越界时按现有切片求和,不抛异常
+ assertEquals(50_000L, AdIntervalMapper.segmentStartMs(UNIFORM, 99));
+ }
+
+ @Test
+ public void prefersDiscontinuityOverCrossDomainAndWindow() {
+ M3u8Evidence evidence = M3u8Evidence.create(
+ List.of("a.ts", "b.ts", "c.ts", "d.ts", "e.ts"),
+ List.of(2),
+ UNIFORM,
+ List.of(false, true, false, false, false));
+
+ AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(evidence, 35_000, -1, 90_000);
+
+ assertEquals(StartOrigin.DISCONTINUITY, start.origin());
+ assertEquals(20_000L, start.startMs());
+ }
+
+ @Test
+ public void fallsBackToCrossDomainWhenNoDiscontinuity() {
+ M3u8Evidence evidence = M3u8Evidence.create(
+ List.of("a.ts", "b.ts", "c.ts", "d.ts", "e.ts"),
+ List.of(),
+ UNIFORM,
+ List.of(false, false, false, true, false));
+
+ AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(evidence, 45_000, -1, 90_000);
+
+ assertEquals(StartOrigin.CROSS_DOMAIN, start.origin());
+ assertEquals(30_000L, start.startMs());
+ }
+
+ @Test
+ public void ignoresBoundariesAtOrAfterClickPosition() {
+ // 断点在 30s,点击位置就是 30s:不能把起点设成终点
+ M3u8Evidence evidence = M3u8Evidence.create(
+ List.of("a.ts", "b.ts", "c.ts", "d.ts", "e.ts"),
+ List.of(3),
+ UNIFORM,
+ List.of(false, false, false, false, false));
+
+ AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(evidence, 30_000, -1, 90_000);
+
+ assertEquals(StartOrigin.FALLBACK_WINDOW, start.origin());
+ assertEquals(0L, start.startMs());
+ }
+
+ @Test
+ public void usesAudioCandidateWhenPlaylistHasNoSignal() {
+ AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(
+ M3u8Evidence.empty(), 60_000, 42_000, 90_000);
+
+ assertEquals(StartOrigin.AUDIO_CANDIDATE, start.origin());
+ assertEquals(42_000L, start.startMs());
+ }
+
+ @Test
+ public void clampsFallbackWindowToZero() {
+ AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(null, 30_000, -1, 90_000);
+
+ assertEquals(StartOrigin.FALLBACK_WINDOW, start.origin());
+ assertEquals(0L, start.startMs());
+ }
+
+ @Test
+ public void appliesDefaultWindowWhenNonPositive() {
+ AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(null, 200_000, -1, 0);
+
+ assertEquals(200_000L - AdIntervalMapper.DEFAULT_FALLBACK_WINDOW_MS, start.startMs());
+ }
+}
From 8ce64374879628a1ce029c193033bc4074d0df95 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sat, 29 Aug 2026 22:40:03 +0800
Subject: [PATCH 02/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9AHLS=20?=
=?UTF-8?q?=E5=88=87=E7=89=87=E7=BB=93=E6=9E=84=E5=BD=92=E5=9B=A0=E9=80=9A?=
=?UTF-8?q?=E9=81=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第二个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 7.1、8.1 节。
- HlsSegmentClassifier:五种信号加权判定(跨域 0.35 / 断点 0.25 /
时长离群 0.20 / 路径特征 0.15 / 头部位置 0.05),置信度低于 0.30
或无可编码信号时弃权。跨域要求区间内外形成对照,整条 playlist 同域
不算证据;时长离群要求区间内一致且与区间外众数差异超过 0.5s。
- AdAttribution:单通道归因结论,score() 按置信度 0.7 + 机制优先级 0.3
加权,ALREADY_HANDLED 强制归零。
- RemediationKind:枚举顺序即成本-风险优先级,priorityWeight() 供仲裁使用。
- AdCategory / RiskLevel:归因类别与风险等级。
位置信号不被 HlsAdRule 支持,encodableCount() 将其排除,避免生成
无法被现有引擎执行的规则。尚未接线到播放器,无行为变化。
Verification: testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ad.feedback.* : 26 tests (11 mapper + 15 classifier), 0 failures, 0 errors
Task-Guard: AF2a-hls-classifier
---
.../android/tv/ad/feedback/AdAttribution.java | 49 ++++
.../android/tv/ad/feedback/AdCategory.java | 26 ++
.../tv/ad/feedback/HlsSegmentClassifier.java | 193 +++++++++++++++
.../tv/ad/feedback/RemediationKind.java | 32 +++
.../android/tv/ad/feedback/RiskLevel.java | 14 ++
.../ad/feedback/HlsSegmentClassifierTest.java | 225 ++++++++++++++++++
6 files changed, 539 insertions(+)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifierTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java
new file mode 100644
index 0000000000..47f6e145ab
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java
@@ -0,0 +1,49 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.List;
+
+/**
+ * 单个通道对区间的归因结论。
+ *
+ * @param channelId 通道标识,"hls" / "domain" / "existing-rule" / "audio" / "speech" / "ai"
+ * @param category 归因类别
+ * @param confidence 通道内自评置信度,0..1
+ * @param risk 采纳该结论的风险等级
+ * @param evidence 人类可读的证据,直接进 UI
+ * @param remediation 建议的去广机制
+ */
+public record AdAttribution(
+ String channelId, AdCategory category, float confidence,
+ RiskLevel risk, List evidence, RemediationKind remediation) {
+
+ public AdAttribution {
+ if (channelId == null || channelId.isBlank()) {
+ throw new IllegalArgumentException("channelId is required");
+ }
+ category = category == null ? AdCategory.UNKNOWN : category;
+ confidence = clamp(confidence);
+ risk = risk == null ? RiskLevel.MEDIUM : risk;
+ evidence = evidence == null ? List.of() : List.copyOf(evidence);
+ remediation = remediation == null ? RemediationKind.NONE : remediation;
+ }
+
+ private static float clamp(float value) {
+ if (Float.isNaN(value)) return 0f;
+ return Math.max(0f, Math.min(1f, value));
+ }
+
+ /**
+ * 仲裁得分:置信度占 0.7,机制的成本-风险优先级占 0.3。
+ * {@link AdCategory#ALREADY_HANDLED} 强制降到最低,见设计文档第 8.2 节。
+ */
+ public float score() {
+ if (category == AdCategory.ALREADY_HANDLED) return 0f;
+ return confidence * 0.7f + remediation.priorityWeight() * 0.3f;
+ }
+
+ /** 是否能产出可落地的规则。 */
+ public boolean actionable() {
+ return remediation != RemediationKind.NONE
+ && remediation != RemediationKind.SESSION_SKIP_ONLY;
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java
new file mode 100644
index 0000000000..a510976414
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdCategory.java
@@ -0,0 +1,26 @@
+package com.fongmi.android.tv.ad.feedback;
+
+/**
+ * 用户反馈区间的归因类别。
+ *
+ * 见 {@code docs/ad-feedback-timerange-design.md} 第 6.2 节。
+ */
+public enum AdCategory {
+ /** 切片来自非本站适配域名。 */
+ THIRD_PARTY_CDN_SEGMENT,
+ /** 固定时长硬插块。 */
+ FIXED_DURATION_BLOCK,
+ /** 被 DISCONTINUITY 包裹的独立块。 */
+ DISCONTINUITY_BLOCK,
+ /** 命中已知广告域名。 */
+ KNOWN_AD_HOST,
+ /** 音频频谱指纹匹配。 */
+ AUDIO_FINGERPRINT,
+ /** 语音关键词命中。 */
+ SPEECH_KEYWORD,
+ /** 压制进正片,无结构特征可用。 */
+ IN_STREAM_BURNED_IN,
+ /** 已被现有规则处理,用户看到的广告来自别处。 */
+ ALREADY_HANDLED,
+ UNKNOWN
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java
new file mode 100644
index 0000000000..d17493f823
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java
@@ -0,0 +1,193 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * 基于 HLS 切片结构的归因通道。零新增运行时成本:全部输入来自
+ * {@code M3u8Parser.parse()} 已产出的证据,不发起新的网络请求。
+ *
+ *
五种信号的权重见设计文档第 7.1 节。生成的条件必须落在
+ * {@code HlsManifestCleaner.Rule} 已支持的范围内,否则规则无法被现有引擎执行。
+ */
+public final class HlsSegmentClassifier {
+
+ public static final String CHANNEL_ID = "hls";
+
+ /** 置信度低于此值时弃权。 */
+ static final float MIN_CONFIDENCE = 0.30f;
+
+ static final float WEIGHT_CROSS_DOMAIN = 0.35f;
+ static final float WEIGHT_DISCONTINUITY = 0.25f;
+ static final float WEIGHT_DURATION_OUTLIER = 0.20f;
+ static final float WEIGHT_PATH_HINT = 0.15f;
+ static final float WEIGHT_HEAD_POSITION = 0.05f;
+
+ /** 区间内切片时长视为「整齐」的标准差上限,秒。 */
+ private static final double UNIFORM_STDDEV_MAX = 0.05d;
+ /** 与区间外众数时长的差异下限,秒。 */
+ private static final double DURATION_GAP_MIN = 0.5d;
+ /** 落在 playlist 头部多少比例内算「片头」。 */
+ private static final double HEAD_RATIO = 0.15d;
+
+ /** 保守的广告路径特征,只匹配明确的目录段。 */
+ private static final List PATH_HINTS =
+ List.of("/ad/", "/ads/", "/adv/", "/preroll/", "/midroll/", "/creative/");
+
+ private HlsSegmentClassifier() {
+ }
+
+ /** 命中的信号,用于生成规则时决定 minimumSignals。 */
+ public record Signals(boolean crossDomain, boolean discontinuity, boolean durationOutlier,
+ boolean pathHint, boolean headPosition) {
+
+ /** 可编码进 {@code HlsAdRule} 的信号数。位置信号不被 HlsAdRule 支持,不计入。 */
+ public int encodableCount() {
+ int count = 0;
+ if (crossDomain) count++;
+ if (discontinuity) count++;
+ if (durationOutlier) count++;
+ if (pathHint) count++;
+ return count;
+ }
+
+ public float weightSum() {
+ float sum = 0f;
+ if (crossDomain) sum += WEIGHT_CROSS_DOMAIN;
+ if (discontinuity) sum += WEIGHT_DISCONTINUITY;
+ if (durationOutlier) sum += WEIGHT_DURATION_OUTLIER;
+ if (pathHint) sum += WEIGHT_PATH_HINT;
+ if (headPosition) sum += WEIGHT_HEAD_POSITION;
+ return sum;
+ }
+ }
+
+ /**
+ * 对区间做结构归因。无法判断时返回 null 表示弃权。
+ */
+ public static AdAttribution classify(AdIntervalEvidence evidence) {
+ if (evidence == null || !evidence.hasSegmentEvidence()) return null;
+ // 区间已被结构化规则整体处理,用户看到的广告来自别处,不应再加 HLS 规则
+ if (evidence.handledByStructuredRule()) return null;
+
+ Signals signals = detect(evidence);
+ float confidence = signals.weightSum();
+ if (confidence < MIN_CONFIDENCE) return null;
+ // 无法编码进规则的结论没有落地价值
+ if (signals.encodableCount() == 0) return null;
+
+ return new AdAttribution(CHANNEL_ID, categoryOf(signals), confidence,
+ RiskLevel.MEDIUM, describe(evidence, signals), RemediationKind.HLS_STRUCTURED_RULE);
+ }
+
+ /** 逐项检测五种信号。 */
+ public static Signals detect(AdIntervalEvidence evidence) {
+ List inside = evidence.inside();
+ List outside = evidence.outside();
+ return new Signals(
+ detectCrossDomain(inside, outside, evidence.playlistHost()),
+ evidence.boundedByDiscontinuity() || inside.get(0).discontinuityBefore(),
+ detectDurationOutlier(inside, outside),
+ detectPathHint(inside),
+ detectHeadPosition(inside, outside));
+ }
+
+ /**
+ * 跨域:区间内切片整体不属于 playlist 域名,且区间外切片属于。
+ * 只有形成对照才算证据 —— 整条 playlist 都跨域说明这就是该站的正常结构。
+ */
+ private static boolean detectCrossDomain(List inside, List outside,
+ String playlistHost) {
+ if (playlistHost == null || playlistHost.isEmpty()) return false;
+ boolean insideForeign = inside.stream()
+ .allMatch(fact -> !fact.host().isEmpty() && !fact.hostEndsWith(playlistHost));
+ if (!insideForeign) return false;
+ if (outside.isEmpty()) return false;
+ return outside.stream()
+ .anyMatch(fact -> fact.hostEndsWith(playlistHost));
+ }
+
+ /**
+ * 时长离群:区间内时长高度一致,且与区间外众数明显不同。
+ * 单独出现时权重不足以触发规则,必须与其他信号叠加。
+ */
+ private static boolean detectDurationOutlier(List inside, List outside) {
+ if (inside.isEmpty() || outside.isEmpty()) return false;
+ if (stdDev(inside) > UNIFORM_STDDEV_MAX) return false;
+ double insideMean = mean(inside);
+ Double outsideMode = mode(outside);
+ if (outsideMode == null) return false;
+ return Math.abs(insideMean - outsideMode) > DURATION_GAP_MIN;
+ }
+
+ private static boolean detectPathHint(List inside) {
+ return inside.stream().anyMatch(fact -> {
+ String path = fact.path().toLowerCase(Locale.US);
+ return PATH_HINTS.stream().anyMatch(path::contains);
+ });
+ }
+
+ /** 位置:区间落在 playlist 头部 15% 以内。 */
+ private static boolean detectHeadPosition(List inside, List outside) {
+ int total = inside.size() + outside.size();
+ if (total == 0) return false;
+ int firstIndex = inside.stream().mapToInt(SegmentFact::index).min().orElse(Integer.MAX_VALUE);
+ return firstIndex <= Math.max(0, (int) Math.floor(total * HEAD_RATIO));
+ }
+
+ private static AdCategory categoryOf(Signals signals) {
+ if (signals.crossDomain()) return AdCategory.THIRD_PARTY_CDN_SEGMENT;
+ if (signals.discontinuity()) return AdCategory.DISCONTINUITY_BLOCK;
+ if (signals.durationOutlier()) return AdCategory.FIXED_DURATION_BLOCK;
+ return AdCategory.UNKNOWN;
+ }
+
+ private static List describe(AdIntervalEvidence evidence, Signals signals) {
+ List inside = evidence.inside();
+ List lines = new ArrayList<>();
+ lines.add(String.format(Locale.US, "区间内 %d 个切片,时长合计 %.1fs",
+ inside.size(), inside.stream().mapToDouble(SegmentFact::durationSec).sum()));
+ if (signals.crossDomain()) {
+ lines.add(String.format(Locale.US, "切片域名 %s 与 playlist 域名 %s 不一致",
+ inside.get(0).host(), evidence.playlistHost()));
+ }
+ if (signals.discontinuity()) lines.add("区间边界存在 #EXT-X-DISCONTINUITY");
+ if (signals.durationOutlier()) {
+ lines.add(String.format(Locale.US, "区间内切片时长一致(%.2fs),与正片切片时长差异明显",
+ mean(inside)));
+ }
+ if (signals.pathHint()) lines.add("切片路径包含广告目录特征");
+ if (signals.headPosition()) lines.add("区间位于播放列表头部");
+ lines.add("起点来源:" + evidence.startOrigin());
+ return lines;
+ }
+
+ private static double mean(List facts) {
+ return facts.stream().mapToDouble(SegmentFact::durationSec).average().orElse(0d);
+ }
+
+ private static double stdDev(List facts) {
+ if (facts.size() < 2) return 0d;
+ double mean = mean(facts);
+ double variance = facts.stream()
+ .mapToDouble(fact -> Math.pow(fact.durationSec() - mean, 2))
+ .sum() / facts.size();
+ return Math.sqrt(variance);
+ }
+
+ /** 区间外切片的众数时长,按 0.1s 粒度归桶。 */
+ private static Double mode(List facts) {
+ Map buckets = new HashMap<>();
+ for (SegmentFact fact : facts) {
+ long bucket = Math.round(fact.durationSec() * 10);
+ buckets.merge(bucket, 1, Integer::sum);
+ }
+ return buckets.entrySet().stream()
+ .max(Map.Entry.comparingByValue())
+ .map(entry -> entry.getKey() / 10d)
+ .orElse(null);
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java
new file mode 100644
index 0000000000..571e6c73fb
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RemediationKind.java
@@ -0,0 +1,32 @@
+package com.fongmi.android.tv.ad.feedback;
+
+/**
+ * 归因结论对应的去广机制。
+ *
+ * {@link #ordinal()} 即成本-风险优先级:越靠前越优先。见设计文档第 8.1 节。
+ * 仲裁器依赖这个顺序,重排枚举会改变仲裁结果。
+ */
+public enum RemediationKind {
+ /** 启用一条已存在但默认关闭的规则,运行时零新增成本。 */
+ ENABLE_EXISTING_RULE,
+ /** 写入 {@code UserAdRule.hosts}。 */
+ HOST_BLACKLIST,
+ /** 写入 {@code HlsAdRule},由 HlsManifestCleaner 执行。 */
+ HLS_STRUCTURED_RULE,
+ /** 写入 {@code UserAdRule.regex}。 */
+ URL_REGEX_RULE,
+ /** 写入 {@code AudioFingerprintRule}(Phase 2)。 */
+ AUDIO_FINGERPRINT_RULE,
+ /** 追加语音关键词(Phase 2,仅建议)。 */
+ SPEECH_KEYWORD,
+ /** 无法泛化,只能本次跳过。 */
+ SESSION_SKIP_ONLY,
+ /** 纯诊断结论,不产出规则。 */
+ NONE;
+
+ /** 成本-风险排序权重,0..1,越大越优先。 */
+ public float priorityWeight() {
+ int span = values().length - 1;
+ return span == 0 ? 1f : 1f - (float) ordinal() / span;
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java
new file mode 100644
index 0000000000..eb02c1a87d
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RiskLevel.java
@@ -0,0 +1,14 @@
+package com.fongmi.android.tv.ad.feedback;
+
+/**
+ * 规则风险等级,沿用智能去广 V2 总设计第 12 节的三级划分。
+ *
+ *
低:广告 host 拦截、严格媒体 URL 排除;
+ * 中:结构化 HLS 区块删除、DOM 声明式动作;
+ * 高:任意 JavaScript、全文正则替换、宽泛固定时长规则。
+ */
+public enum RiskLevel {
+ LOW,
+ MEDIUM,
+ HIGH
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifierTest.java
new file mode 100644
index 0000000000..7ca476c7aa
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifierTest.java
@@ -0,0 +1,225 @@
+package com.fongmi.android.tv.ad.feedback;
+
+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 org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class HlsSegmentClassifierTest {
+
+ private static final String PLAYLIST_HOST = "v.example.com";
+
+ /** 构造区间内外切片:inside 用广告域名,outside 用 playlist 域名。 */
+ private static AdIntervalEvidence evidence(List inside, List outside) {
+ return evidence(inside, outside, false, List.of());
+ }
+
+ private static AdIntervalEvidence evidence(List inside, List outside,
+ boolean bounded, List removedRuleIds) {
+ return new AdIntervalEvidence(
+ 10_000, 40_000, StartOrigin.USER_MARKED,
+ "site", "站点", "剧名", "线路", "第 1 集",
+ PLAYLIST_HOST, "/play/index.m3u8", true,
+ inside, outside, bounded, false,
+ removedRuleIds, false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+ }
+
+ private static List segments(int from, int count, String host, String pathPrefix,
+ double duration, boolean discontinuityOnFirst) {
+ List facts = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ facts.add(new SegmentFact(from + i, host, pathPrefix + (from + i) + ".ts",
+ duration, discontinuityOnFirst && i == 0));
+ }
+ return facts;
+ }
+
+ /** 区间外切片分成前后两段,避免与区间内下标重叠。 */
+ private static List outsideAround(int insideFrom, int insideCount, int tailCount,
+ String host, double duration) {
+ List facts = new ArrayList<>(segments(0, insideFrom, host, "/seg/", duration, false));
+ facts.addAll(segments(insideFrom + insideCount, tailCount, host, "/seg/", duration, false));
+ return facts;
+ }
+
+ @Test
+ public void crossDomainWithDiscontinuityIsHighConfidence() {
+ // 区间在中段(下标 10-14,总 25 片),位置信号不参与
+ AdIntervalEvidence evidence = evidence(
+ segments(10, 5, "ad-cdn.other.com", "/seg/", 6.4, true),
+ outsideAround(10, 5, 10, PLAYLIST_HOST, 8.0));
+
+ AdAttribution attribution = HlsSegmentClassifier.classify(evidence);
+
+ assertNotNull(attribution);
+ assertEquals(AdCategory.THIRD_PARTY_CDN_SEGMENT, attribution.category());
+ assertEquals(RemediationKind.HLS_STRUCTURED_RULE, attribution.remediation());
+ // 跨域 0.35 + 断点 0.25 + 时长离群 0.20 = 0.80
+ assertEquals(0.80f, attribution.confidence(), 0.001f);
+ assertTrue(attribution.actionable());
+ assertFalse(HlsSegmentClassifier.detect(evidence).headPosition());
+ }
+
+ @Test
+ public void headPositionAddsSmallWeightOnTopOfStructuralSignals() {
+ // 同样三个结构信号,但区间在头部:额外 +0.05
+ AdIntervalEvidence evidence = evidence(
+ segments(3, 5, "ad-cdn.other.com", "/seg/", 6.4, true),
+ outsideAround(3, 5, 17, PLAYLIST_HOST, 8.0));
+
+ AdAttribution attribution = HlsSegmentClassifier.classify(evidence);
+
+ assertNotNull(attribution);
+ assertEquals(0.85f, attribution.confidence(), 0.001f);
+ }
+
+ @Test
+ public void sameDomainThroughoutIsNotCrossDomainEvidence() {
+ // 整条 playlist 同域:这是该站正常结构,跨域信号必须不成立
+ AdIntervalEvidence evidence = evidence(
+ segments(3, 5, PLAYLIST_HOST, "/seg/", 8.0, false),
+ segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false));
+
+ HlsSegmentClassifier.Signals signals = HlsSegmentClassifier.detect(evidence);
+
+ assertFalse(signals.crossDomain());
+ // 无任何信号,整体弃权
+ assertNull(HlsSegmentClassifier.classify(evidence));
+ }
+
+ @Test
+ public void crossDomainRequiresOutsideContrast() {
+ // 区间外为空,无法形成对照
+ AdIntervalEvidence evidence = evidence(
+ segments(0, 5, "ad-cdn.other.com", "/seg/", 6.4, false),
+ List.of());
+
+ assertFalse(HlsSegmentClassifier.detect(evidence).crossDomain());
+ }
+
+ @Test
+ public void subdomainOfPlaylistHostCountsAsSameDomain() {
+ AdIntervalEvidence evidence = evidence(
+ segments(3, 3, "cdn." + PLAYLIST_HOST, "/seg/", 8.0, false),
+ segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false));
+
+ assertFalse(HlsSegmentClassifier.detect(evidence).crossDomain());
+ }
+
+ @Test
+ public void durationOutlierNeedsUniformInsideAndDifferentOutsideMode() {
+ // 区间内时长不齐(6.4 与 9.0),不算离群证据
+ List inside = new ArrayList<>(segments(3, 2, "ad.other.com", "/seg/", 6.4, false));
+ inside.add(new SegmentFact(5, "ad.other.com", "/seg/5.ts", 9.0, false));
+
+ AdIntervalEvidence evidence = evidence(inside, segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false));
+
+ assertFalse(HlsSegmentClassifier.detect(evidence).durationOutlier());
+ }
+
+ @Test
+ public void durationCloseToOutsideModeIsNotOutlier() {
+ // 6.4 与众数 6.7 只差 0.3s,低于 0.5s 门槛
+ AdIntervalEvidence evidence = evidence(
+ segments(3, 4, "ad.other.com", "/seg/", 6.4, false),
+ segments(0, 10, PLAYLIST_HOST, "/seg/", 6.7, false));
+
+ assertFalse(HlsSegmentClassifier.detect(evidence).durationOutlier());
+ }
+
+ @Test
+ public void pathHintMatchesOnlyExplicitAdDirectories() {
+ AdIntervalEvidence hit = evidence(
+ segments(3, 2, PLAYLIST_HOST, "/ads/spot", 8.0, false),
+ segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false));
+ assertTrue(HlsSegmentClassifier.detect(hit).pathHint());
+
+ // "upload/" 含 ad 子串但不是目录段,不应命中
+ AdIntervalEvidence miss = evidence(
+ segments(3, 2, PLAYLIST_HOST, "/upload/download", 8.0, false),
+ segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false));
+ assertFalse(HlsSegmentClassifier.detect(miss).pathHint());
+ }
+
+ @Test
+ public void headPositionOnlyForLeadingSegments() {
+ AdIntervalEvidence head = evidence(
+ segments(0, 3, "ad.other.com", "/seg/", 6.4, false),
+ segments(3, 20, PLAYLIST_HOST, "/seg/", 8.0, false));
+ assertTrue(HlsSegmentClassifier.detect(head).headPosition());
+
+ AdIntervalEvidence middle = evidence(
+ segments(12, 3, "ad.other.com", "/seg/", 6.4, false),
+ segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false));
+ assertFalse(HlsSegmentClassifier.detect(middle).headPosition());
+ }
+
+ @Test
+ public void abstainsWhenAlreadyHandledByStructuredRule() {
+ AdIntervalEvidence evidence = evidence(
+ segments(3, 5, "ad-cdn.other.com", "/ads/", 6.4, true),
+ segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false),
+ true, List.of("baofeng-preroll"));
+
+ assertNull(HlsSegmentClassifier.classify(evidence));
+ }
+
+ @Test
+ public void abstainsWithoutSegmentEvidence() {
+ assertNull(HlsSegmentClassifier.classify(null));
+
+ AdIntervalEvidence empty = evidence(List.of(), segments(0, 10, PLAYLIST_HOST, "/seg/", 8.0, false));
+ assertNull(HlsSegmentClassifier.classify(empty));
+ }
+
+ @Test
+ public void abstainsForNonHlsPlayback() {
+ AdIntervalEvidence nonHls = new AdIntervalEvidence(
+ 10_000, 40_000, StartOrigin.USER_MARKED,
+ "site", "站点", "剧名", "线路", "第 1 集",
+ PLAYLIST_HOST, "/play/video.mp4", false,
+ segments(3, 5, "ad.other.com", "/ads/", 6.4, true),
+ segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false),
+ true, false, List.of(), false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+
+ assertNull(HlsSegmentClassifier.classify(nonHls));
+ }
+
+ @Test
+ public void headPositionAloneIsBelowConfidenceFloor() {
+ // 只有位置信号 0.05,低于 0.30 门槛
+ AdIntervalEvidence evidence = evidence(
+ segments(0, 3, PLAYLIST_HOST, "/seg/", 8.0, false),
+ segments(3, 20, PLAYLIST_HOST, "/seg/", 8.0, false));
+
+ HlsSegmentClassifier.Signals signals = HlsSegmentClassifier.detect(evidence);
+ assertTrue(signals.headPosition());
+ assertNull(HlsSegmentClassifier.classify(evidence));
+ }
+
+ @Test
+ public void positionSignalIsNotEncodableIntoRule() {
+ HlsSegmentClassifier.Signals signals =
+ new HlsSegmentClassifier.Signals(false, false, false, false, true);
+
+ // HlsAdRule 不支持位置条件,可编码信号数为 0
+ assertEquals(0, signals.encodableCount());
+ }
+
+ @Test
+ public void discontinuityOnFirstInsideSegmentCountsAsBoundary() {
+ AdIntervalEvidence evidence = evidence(
+ segments(3, 4, "ad.other.com", "/seg/", 6.4, true),
+ segments(0, 20, PLAYLIST_HOST, "/seg/", 8.0, false));
+
+ assertTrue(HlsSegmentClassifier.detect(evidence).discontinuity());
+ }
+}
From 39bbdf2510a82b3a368eeecd53f894c3822c4a43 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sat, 29 Aug 2026 22:50:37 +0800
Subject: [PATCH 03/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=9F=9F?=
=?UTF-8?q?=E5=90=8D=E4=BF=A1=E8=AA=89=E4=B8=8E=E6=97=A2=E6=9C=89=E8=A7=84?=
=?UTF-8?q?=E5=88=99=E5=BD=92=E5=9B=A0=E9=80=9A=E9=81=93=EF=BC=8C=E4=BB=A5?=
=?UTF-8?q?=E5=8F=8A=E8=B7=A8=E9=80=9A=E9=81=93=E4=BB=B2=E8=A3=81=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第三个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 7.2、7.3、8 节。
- DomainReputationClassifier:三层比对。命中现有黑名单时输出
ALREADY_HANDLED 诊断(黑名单只在 WebView 层生效,不拦播放器直连切片);
不在本站基线内则判 THIRD_PARTY_CDN_SEGMENT;接口学习候选命中加 0.15。
无基线数据时弃权,避免首次播放该站即误判。
- ExistingRuleClassifier:优先建议启用已存在但默认关闭的规则,这是成本
最低的修复;编译失败的规则只报诊断不建议启用。另报旧启发式引擎生效、
正片保护规则可能误保护。
- AdAttributionArbiter:同类别按概率或合并置信度并取成本最低机制,跨类别
按 score() 排序(置信度 0.7 + 机制优先级 0.3)。不可落地的结论一律降级
为诊断,只剩诊断时兜底为 SESSION_SKIP_ONLY 并合并证据供 UI 展示。
三个分类器均以 record Input 接收外部数据,不触碰 SharedPreferences,
可在纯 JVM 下单测。尚未接线到播放器,无行为变化。
Verification: testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ad.feedback.* : 49 tests (11 mapper + 15 hls + 7 domain + 7 existing + 9 arbiter), 0 failures, 0 errors
Task-Guard: AF2b-domain-arbiter
---
.../tv/ad/feedback/AdAttributionArbiter.java | 127 +++++++++++++++
.../feedback/DomainReputationClassifier.java | 130 ++++++++++++++++
.../ad/feedback/ExistingRuleClassifier.java | 117 ++++++++++++++
.../ad/feedback/AdAttributionArbiterTest.java | 144 ++++++++++++++++++
.../DomainReputationClassifierTest.java | 109 +++++++++++++
.../feedback/ExistingRuleClassifierTest.java | 112 ++++++++++++++
6 files changed, 739 insertions(+)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifierTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java
new file mode 100644
index 0000000000..832ee1ae36
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java
@@ -0,0 +1,127 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.EnumMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 跨通道仲裁:合并同类结论、按成本-风险排序、选出最优方案。
+ *
+ * 核心取舍见设计文档第 8 节:「最好的去广告方式」不等于「置信度最高的方式」。
+ * 同等置信度下优先选成本更低、风险更小的机制。
+ */
+public final class AdAttributionArbiter {
+
+ private AdAttributionArbiter() {
+ }
+
+ /**
+ * 仲裁结果。
+ *
+ * @param preferred 首选方案,全部弃权时为 null
+ * @param alternatives 备选方案,按得分降序
+ * @param diagnostics 纯诊断结论,不产出规则但需展示
+ */
+ public record Verdict(AdAttribution preferred, List alternatives,
+ List diagnostics) {
+
+ public Verdict {
+ alternatives = alternatives == null ? List.of() : List.copyOf(alternatives);
+ diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics);
+ }
+
+ /** 是否有可落地的规则方案。 */
+ public boolean hasActionablePlan() {
+ return preferred != null && preferred.actionable();
+ }
+
+ /** 全部通道都没给出结论。 */
+ public boolean empty() {
+ return preferred == null && diagnostics.isEmpty();
+ }
+ }
+
+ /**
+ * 仲裁若干通道结论。入参可含 null(通道弃权),会被忽略。
+ */
+ public static Verdict arbitrate(List attributions) {
+ List valid = new ArrayList<>();
+ if (attributions != null) {
+ for (AdAttribution attribution : attributions) {
+ if (attribution != null) valid.add(attribution);
+ }
+ }
+ if (valid.isEmpty()) return new Verdict(null, List.of(), List.of());
+
+ List diagnostics = new ArrayList<>();
+ List actionable = new ArrayList<>();
+ for (AdAttribution attribution : valid) {
+ if (attribution.actionable()) actionable.add(attribution);
+ else diagnostics.add(attribution);
+ }
+
+ List merged = mergeSameCategory(actionable);
+ merged.sort(Comparator.comparingDouble(AdAttribution::score).reversed()
+ .thenComparingInt(a -> a.remediation().ordinal()));
+
+ if (merged.isEmpty()) {
+ // 只有诊断结论:能做的只有本次跳过
+ return new Verdict(sessionSkipFallback(diagnostics), List.of(), diagnostics);
+ }
+ return new Verdict(merged.get(0), merged.subList(1, merged.size()), diagnostics);
+ }
+
+ /**
+ * 同一 {@link AdCategory} 的多通道结论合并为一条:置信度按概率或
+ * {@code 1 - Π(1 - cᵢ)},机制取成本最低的那个,证据全量保留。
+ */
+ private static List mergeSameCategory(List attributions) {
+ Map> grouped = new EnumMap<>(AdCategory.class);
+ for (AdAttribution attribution : attributions) {
+ grouped.computeIfAbsent(attribution.category(), key -> new ArrayList<>()).add(attribution);
+ }
+
+ List merged = new ArrayList<>();
+ for (Map.Entry> entry : grouped.entrySet()) {
+ List group = entry.getValue();
+ if (group.size() == 1) {
+ merged.add(group.get(0));
+ continue;
+ }
+ float inverse = 1f;
+ for (AdAttribution attribution : group) inverse *= (1f - attribution.confidence());
+ float confidence = 1f - inverse;
+
+ AdAttribution cheapest = group.stream()
+ .min(Comparator.comparingInt(a -> a.remediation().ordinal()))
+ .orElse(group.get(0));
+ RiskLevel risk = group.stream()
+ .map(AdAttribution::risk)
+ .min(Comparator.comparingInt(Enum::ordinal))
+ .orElse(cheapest.risk());
+
+ Set lines = new LinkedHashSet<>();
+ List channels = new ArrayList<>();
+ for (AdAttribution attribution : group) {
+ lines.addAll(attribution.evidence());
+ channels.add(attribution.channelId());
+ }
+ merged.add(new AdAttribution(String.join("+", channels), entry.getKey(),
+ confidence, risk, List.copyOf(lines), cheapest.remediation()));
+ }
+ return merged;
+ }
+
+ /** 只有诊断结论时的兜底:本次跳过。 */
+ private static AdAttribution sessionSkipFallback(List diagnostics) {
+ List lines = new ArrayList<>();
+ lines.add("未能归纳出可复用的去广规则");
+ for (AdAttribution diagnostic : diagnostics) lines.addAll(diagnostic.evidence());
+ return new AdAttribution("arbiter", AdCategory.IN_STREAM_BURNED_IN,
+ 0.3f, RiskLevel.LOW, lines, RemediationKind.SESSION_SKIP_ONLY);
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java
new file mode 100644
index 0000000000..f9fa403c5b
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java
@@ -0,0 +1,130 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * 基于域名信誉的归因通道,实现「非当前适配的主流域名」这条判据。
+ *
+ * 三层比对见设计文档第 7.2 节。所有外部数据由调用方以 {@link Input} 传入,
+ * 类本身不触碰 SharedPreferences,因此可在纯 JVM 下单测。
+ */
+public final class DomainReputationClassifier {
+
+ public static final String CHANNEL_ID = "domain";
+
+ /** 命中已知黑名单时的置信度。 */
+ static final float CONFIDENCE_KNOWN_HOST = 0.95f;
+ /** 仅「不在本站基线内」时的置信度。 */
+ static final float CONFIDENCE_UNKNOWN_HOST = 0.55f;
+ /** 接口候选也认为是广告域名时的加成。 */
+ static final float BONUS_INTERFACE_CANDIDATE = 0.15f;
+
+ private DomainReputationClassifier() {
+ }
+
+ /**
+ * 分类输入。
+ *
+ * @param blacklistedHosts 现有黑名单,来自 {@code RuleConfig.get().getAds()}
+ * @param siteBaselineHosts 本站历史正常域名,来自 SitePlaylistHostBaseline
+ * @param interfaceCandidateHosts 接口学习的待审候选域名
+ * @param interfaceSourceName 命中候选时展示的来源接口名
+ */
+ public record Input(List blacklistedHosts, List siteBaselineHosts,
+ List interfaceCandidateHosts, String interfaceSourceName) {
+
+ public Input {
+ blacklistedHosts = blacklistedHosts == null ? List.of() : List.copyOf(blacklistedHosts);
+ siteBaselineHosts = siteBaselineHosts == null ? List.of() : List.copyOf(siteBaselineHosts);
+ interfaceCandidateHosts = interfaceCandidateHosts == null ? List.of() : List.copyOf(interfaceCandidateHosts);
+ interfaceSourceName = interfaceSourceName == null ? "" : interfaceSourceName;
+ }
+
+ public static Input empty() {
+ return new Input(List.of(), List.of(), List.of(), "");
+ }
+ }
+
+ /** 无结论时返回 null 表示弃权。 */
+ public static AdAttribution classify(AdIntervalEvidence evidence, Input input) {
+ if (evidence == null || evidence.inside().isEmpty()) return null;
+ Input safe = input == null ? Input.empty() : input;
+
+ Set foreignHosts = foreignHosts(evidence);
+ if (foreignHosts.isEmpty()) return null;
+
+ List matchedBlacklist = matching(foreignHosts, safe.blacklistedHosts());
+ List matchedCandidates = matching(foreignHosts, safe.interfaceCandidateHosts());
+
+ if (!matchedBlacklist.isEmpty()) {
+ // 已在黑名单里却仍被用户看到:拦截路径没覆盖播放器直连的切片请求。
+ // 结论是诊断,落地方式应改用 HLS 结构化规则。
+ List evidenceLines = new ArrayList<>();
+ evidenceLines.add("切片域名已在广告黑名单中:" + String.join("、", matchedBlacklist));
+ evidenceLines.add("黑名单主要在 WebView 请求拦截生效,不拦播放器直连的切片请求");
+ evidenceLines.add("建议改用 HLS 结构化规则删除这些切片");
+ return new AdAttribution(CHANNEL_ID, AdCategory.ALREADY_HANDLED,
+ CONFIDENCE_KNOWN_HOST, RiskLevel.LOW, evidenceLines, RemediationKind.NONE);
+ }
+
+ // 无基线数据时不能断言「非本站域名」,避免首次播放即误判
+ if (safe.siteBaselineHosts().isEmpty() && matchedCandidates.isEmpty()) return null;
+
+ boolean outsideBaseline = !safe.siteBaselineHosts().isEmpty()
+ && foreignHosts.stream().noneMatch(host -> endsWithAny(host, safe.siteBaselineHosts()));
+ if (!outsideBaseline && matchedCandidates.isEmpty()) return null;
+
+ float confidence = outsideBaseline ? CONFIDENCE_UNKNOWN_HOST : 0f;
+ List evidenceLines = new ArrayList<>();
+ if (outsideBaseline) {
+ evidenceLines.add("切片域名 " + String.join("、", foreignHosts) + " 不在本站常用域名中");
+ }
+ if (!matchedCandidates.isEmpty()) {
+ confidence += BONUS_INTERFACE_CANDIDATE;
+ String source = safe.interfaceSourceName().isEmpty() ? "接口规则" : safe.interfaceSourceName();
+ evidenceLines.add(source + "也将其列为广告域名候选");
+ }
+ evidenceLines.add("起点来源:" + evidence.startOrigin());
+
+ return new AdAttribution(CHANNEL_ID, AdCategory.THIRD_PARTY_CDN_SEGMENT,
+ confidence, RiskLevel.LOW, evidenceLines, RemediationKind.HOST_BLACKLIST);
+ }
+
+ /** 区间内不属于 playlist 域名的切片 host,去重保序。 */
+ private static Set foreignHosts(AdIntervalEvidence evidence) {
+ Set hosts = new LinkedHashSet<>();
+ String playlistHost = evidence.playlistHost();
+ for (SegmentFact fact : evidence.inside()) {
+ if (fact.host().isEmpty()) continue;
+ if (!playlistHost.isEmpty() && fact.hostEndsWith(playlistHost)) continue;
+ hosts.add(fact.host().toLowerCase(Locale.US));
+ }
+ return hosts;
+ }
+
+ private static List matching(Set hosts, List patterns) {
+ List matched = new ArrayList<>();
+ for (String host : hosts) {
+ if (endsWithAny(host, patterns)) matched.add(host);
+ }
+ return matched;
+ }
+
+ /**
+ * host 是否命中任一片段。黑名单条目多为域名片段(如 {@code doubleclick.net}),
+ * 沿用现有拦截语义:后缀匹配或包含匹配。
+ */
+ private static boolean endsWithAny(String host, List patterns) {
+ String lower = host.toLowerCase(Locale.US);
+ for (String pattern : patterns) {
+ if (pattern == null || pattern.isBlank()) continue;
+ String target = pattern.toLowerCase(Locale.US).trim();
+ if (lower.equals(target) || lower.endsWith("." + target) || lower.contains(target)) return true;
+ }
+ return false;
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java
new file mode 100644
index 0000000000..2bf296d0d3
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java
@@ -0,0 +1,117 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * 诊断通道:回答「为什么现有机制没拦住」。
+ *
+ * 本通道不产出新规则,但会给出成本最低的修复建议 —— 启用一条已存在
+ * 但默认关闭的规则,永远优于新建规则。见设计文档第 7.3、8.1 节。
+ */
+public final class ExistingRuleClassifier {
+
+ public static final String CHANNEL_ID = "existing-rule";
+
+ /** 命中一条已存在但未启用的规则时的置信度。 */
+ static final float CONFIDENCE_DISABLED_RULE = 0.75f;
+
+ private ExistingRuleClassifier() {
+ }
+
+ /**
+ * 一条内置/外部 HLS 规则的当前状态,对应 {@code HlsRuleConfig.Entry} 的子集。
+ *
+ * @param key 复合状态键
+ * @param id 规则 id
+ * @param name 规则名
+ * @param enabled 当前是否启用
+ * @param valid 编译是否通过
+ * @param hostSuffixes 该规则针对的切片域名后缀
+ */
+ public record RuleState(String key, String id, String name, boolean enabled,
+ boolean valid, List hostSuffixes) {
+
+ public RuleState {
+ key = key == null ? "" : key;
+ id = id == null ? "" : id;
+ name = name == null ? "" : name;
+ hostSuffixes = hostSuffixes == null ? List.of() : List.copyOf(hostSuffixes);
+ }
+ }
+
+ /**
+ * 分类输入。
+ *
+ * @param hlsRules 已知的 HLS 规则状态
+ * @param legacyHeuristicActive 旧启发式引擎当次是否生效
+ * @param protectingExcludes 可能误保护广告切片的正片保护正则
+ */
+ public record Input(List hlsRules, boolean legacyHeuristicActive,
+ List protectingExcludes) {
+
+ public Input {
+ hlsRules = hlsRules == null ? List.of() : List.copyOf(hlsRules);
+ protectingExcludes = protectingExcludes == null ? List.of() : List.copyOf(protectingExcludes);
+ }
+
+ public static Input empty() {
+ return new Input(List.of(), false, List.of());
+ }
+ }
+
+ /** 无诊断结论时返回 null。 */
+ public static AdAttribution classify(AdIntervalEvidence evidence, Input input) {
+ if (evidence == null || evidence.inside().isEmpty()) return null;
+ Input safe = input == null ? Input.empty() : input;
+
+ RuleState disabled = findDisabledMatch(evidence, safe.hlsRules());
+ if (disabled != null) {
+ List lines = new ArrayList<>();
+ lines.add(String.format(Locale.US, "已有规则「%s」覆盖该域名但当前未启用",
+ disabled.name().isEmpty() ? disabled.id() : disabled.name()));
+ lines.add("启用它即可拦掉这段广告,无需新建规则");
+ return new AdAttribution(CHANNEL_ID, AdCategory.DISCONTINUITY_BLOCK,
+ CONFIDENCE_DISABLED_RULE, RiskLevel.LOW, lines,
+ RemediationKind.ENABLE_EXISTING_RULE);
+ }
+
+ List lines = new ArrayList<>();
+ if (evidence.handledByStructuredRule()) {
+ lines.add("该区间已被规则 " + String.join("、", evidence.alreadyRemovedByStructuredRuleIds())
+ + " 处理,用户看到的广告来自其他来源");
+ }
+ if (safe.legacyHeuristicActive()) {
+ lines.add("旧启发式引擎当次生效,其判定不依赖规则,可能与结构化结论冲突");
+ }
+ if (!safe.protectingExcludes().isEmpty()) {
+ lines.add("存在 " + safe.protectingExcludes().size()
+ + " 条正片保护规则,可能阻止了这些切片被删除");
+ }
+ RuleState invalid = findInvalid(safe.hlsRules());
+ if (invalid != null) {
+ lines.add("规则「" + (invalid.name().isEmpty() ? invalid.id() : invalid.name())
+ + "」编译失败,未参与本次净化");
+ }
+ if (lines.isEmpty()) return null;
+
+ return new AdAttribution(CHANNEL_ID, AdCategory.ALREADY_HANDLED,
+ 0.5f, RiskLevel.LOW, lines, RemediationKind.NONE);
+ }
+
+ /** 找出一条覆盖本区间域名、有效但未启用的规则。 */
+ private static RuleState findDisabledMatch(AdIntervalEvidence evidence, List rules) {
+ for (RuleState rule : rules) {
+ if (rule.enabled() || !rule.valid() || rule.hostSuffixes().isEmpty()) continue;
+ boolean covers = evidence.inside().stream()
+ .anyMatch(fact -> rule.hostSuffixes().stream().anyMatch(fact::hostEndsWith));
+ if (covers) return rule;
+ }
+ return null;
+ }
+
+ private static RuleState findInvalid(List rules) {
+ return rules.stream().filter(rule -> !rule.valid()).findFirst().orElse(null);
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java
new file mode 100644
index 0000000000..b09c7c8de6
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java
@@ -0,0 +1,144 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class AdAttributionArbiterTest {
+
+ private static AdAttribution of(String channel, AdCategory category, float confidence,
+ RemediationKind kind) {
+ return new AdAttribution(channel, category, confidence, RiskLevel.MEDIUM,
+ List.of(channel + " 证据"), kind);
+ }
+
+ @Test
+ public void prefersCheaperMechanismAtComparableConfidence() {
+ // 域名黑名单置信度略低,但成本与风险都更低
+ AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 0.80f,
+ RemediationKind.HLS_STRUCTURED_RULE);
+ AdAttribution domain = of("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.78f,
+ RemediationKind.HOST_BLACKLIST);
+
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(hls, domain));
+
+ assertEquals(RemediationKind.HOST_BLACKLIST, verdict.preferred().remediation());
+ assertTrue(verdict.hasActionablePlan());
+ assertEquals(1, verdict.alternatives().size());
+ }
+
+ @Test
+ public void enablingExistingRuleOutranksNewRuleEvenAtLowerConfidence() {
+ AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 0.85f,
+ RemediationKind.HLS_STRUCTURED_RULE);
+ AdAttribution existing = of("existing-rule", AdCategory.FIXED_DURATION_BLOCK, 0.75f,
+ RemediationKind.ENABLE_EXISTING_RULE);
+
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(hls, existing));
+
+ assertEquals(RemediationKind.ENABLE_EXISTING_RULE, verdict.preferred().remediation());
+ }
+
+ @Test
+ public void muchHigherConfidenceStillWinsOverCheaperMechanism() {
+ // 置信度占 0.7 权重,差距足够大时仍可翻盘
+ AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 1.0f,
+ RemediationKind.HLS_STRUCTURED_RULE);
+ AdAttribution domain = of("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.30f,
+ RemediationKind.HOST_BLACKLIST);
+
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(hls, domain));
+
+ assertEquals(RemediationKind.HLS_STRUCTURED_RULE, verdict.preferred().remediation());
+ }
+
+ @Test
+ public void mergesSameCategoryWithProbabilisticOr() {
+ AdAttribution first = of("hls", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.6f,
+ RemediationKind.HLS_STRUCTURED_RULE);
+ AdAttribution second = of("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.5f,
+ RemediationKind.HOST_BLACKLIST);
+
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(first, second));
+
+ // 1 - (1-0.6)(1-0.5) = 0.8
+ assertEquals(0.8f, verdict.preferred().confidence(), 0.0001f);
+ // 合并后取成本更低的机制
+ assertEquals(RemediationKind.HOST_BLACKLIST, verdict.preferred().remediation());
+ // 证据合并,通道名拼接
+ assertEquals(2, verdict.preferred().evidence().size());
+ assertTrue(verdict.preferred().channelId().contains("hls"));
+ assertTrue(verdict.preferred().channelId().contains("domain"));
+ assertTrue(verdict.alternatives().isEmpty());
+ }
+
+ @Test
+ public void alreadyHandledIsDemotedToDiagnostics() {
+ AdAttribution diagnosis = new AdAttribution("domain", AdCategory.ALREADY_HANDLED, 0.95f,
+ RiskLevel.LOW, List.of("已在黑名单中"), RemediationKind.NONE);
+ AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 0.4f,
+ RemediationKind.HLS_STRUCTURED_RULE);
+
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(diagnosis, hls));
+
+ // 置信度 0.95 远高于 0.4,但不可落地,只能进诊断
+ assertEquals(RemediationKind.HLS_STRUCTURED_RULE, verdict.preferred().remediation());
+ assertEquals(1, verdict.diagnostics().size());
+ assertEquals(AdCategory.ALREADY_HANDLED, verdict.diagnostics().get(0).category());
+ }
+
+ @Test
+ public void diagnosticsOnlyFallsBackToSessionSkip() {
+ AdAttribution diagnosis = new AdAttribution("existing-rule", AdCategory.ALREADY_HANDLED, 0.5f,
+ RiskLevel.LOW, List.of("旧启发式引擎生效"), RemediationKind.NONE);
+
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(diagnosis));
+
+ assertEquals(RemediationKind.SESSION_SKIP_ONLY, verdict.preferred().remediation());
+ assertEquals(AdCategory.IN_STREAM_BURNED_IN, verdict.preferred().category());
+ assertFalse(verdict.hasActionablePlan());
+ // 诊断证据被并入兜底结论,便于 UI 一次展示
+ assertTrue(verdict.preferred().evidence().stream()
+ .anyMatch(line -> line.contains("旧启发式引擎生效")));
+ }
+
+ @Test
+ public void allChannelsAbstainYieldsEmptyVerdict() {
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(
+ Arrays.asList(null, null));
+
+ assertNull(verdict.preferred());
+ assertTrue(verdict.empty());
+ assertFalse(verdict.hasActionablePlan());
+ }
+
+ @Test
+ public void handlesNullListAndSkipsNullEntries() {
+ assertTrue(AdAttributionArbiter.arbitrate(null).empty());
+
+ AdAttribution hls = of("hls", AdCategory.DISCONTINUITY_BLOCK, 0.6f,
+ RemediationKind.HLS_STRUCTURED_RULE);
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(
+ Arrays.asList(null, hls, null));
+
+ assertEquals(RemediationKind.HLS_STRUCTURED_RULE, verdict.preferred().remediation());
+ }
+
+ @Test
+ public void mergedRiskTakesLowestOfGroup() {
+ AdAttribution medium = new AdAttribution("hls", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.5f,
+ RiskLevel.MEDIUM, List.of("a"), RemediationKind.HLS_STRUCTURED_RULE);
+ AdAttribution low = new AdAttribution("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.5f,
+ RiskLevel.LOW, List.of("b"), RemediationKind.HOST_BLACKLIST);
+
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(medium, low));
+
+ assertEquals(RiskLevel.LOW, verdict.preferred().risk());
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java
new file mode 100644
index 0000000000..f4000a7759
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java
@@ -0,0 +1,109 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.List;
+
+public class DomainReputationClassifierTest {
+
+ private static final String PLAYLIST_HOST = "v.example.com";
+
+ private static AdIntervalEvidence evidenceWith(String... insideHosts) {
+ List inside = new java.util.ArrayList<>();
+ for (int i = 0; i < insideHosts.length; i++) {
+ inside.add(new SegmentFact(i, insideHosts[i], "/seg/" + i + ".ts", 6.4, false));
+ }
+ return new AdIntervalEvidence(
+ 10_000, 40_000, StartOrigin.USER_MARKED,
+ "site", "站点", "剧名", "线路", "第 1 集",
+ PLAYLIST_HOST, "/play/index.m3u8", true,
+ inside, List.of(new SegmentFact(9, PLAYLIST_HOST, "/seg/9.ts", 8.0, false)),
+ false, false, List.of(), false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+ }
+
+ @Test
+ public void blacklistedHostYieldsAlreadyHandledDiagnosis() {
+ DomainReputationClassifier.Input input = new DomainReputationClassifier.Input(
+ List.of("doubleclick.net"), List.of(PLAYLIST_HOST), List.of(), "");
+
+ AdAttribution attribution = DomainReputationClassifier.classify(
+ evidenceWith("ads.doubleclick.net"), input);
+
+ assertNotNull(attribution);
+ // 已在黑名单却仍被看到,说明拦截路径没覆盖播放器直连请求
+ assertEquals(AdCategory.ALREADY_HANDLED, attribution.category());
+ assertEquals(RemediationKind.NONE, attribution.remediation());
+ // ALREADY_HANDLED 的得分强制归零
+ assertEquals(0f, attribution.score(), 0.0001f);
+ }
+
+ @Test
+ public void hostOutsideSiteBaselineIsThirdPartyCdn() {
+ DomainReputationClassifier.Input input = new DomainReputationClassifier.Input(
+ List.of(), List.of(PLAYLIST_HOST, "cdn.example.com"), List.of(), "");
+
+ AdAttribution attribution = DomainReputationClassifier.classify(
+ evidenceWith("ad-cdn.other.com"), input);
+
+ assertNotNull(attribution);
+ assertEquals(AdCategory.THIRD_PARTY_CDN_SEGMENT, attribution.category());
+ assertEquals(RemediationKind.HOST_BLACKLIST, attribution.remediation());
+ assertEquals(RiskLevel.LOW, attribution.risk());
+ assertEquals(DomainReputationClassifier.CONFIDENCE_UNKNOWN_HOST,
+ attribution.confidence(), 0.0001f);
+ }
+
+ @Test
+ public void interfaceCandidateAddsConfidenceBonus() {
+ DomainReputationClassifier.Input input = new DomainReputationClassifier.Input(
+ List.of(), List.of(PLAYLIST_HOST), List.of("ad-cdn.other.com"), "饭太硬");
+
+ AdAttribution attribution = DomainReputationClassifier.classify(
+ evidenceWith("ad-cdn.other.com"), input);
+
+ assertNotNull(attribution);
+ assertEquals(DomainReputationClassifier.CONFIDENCE_UNKNOWN_HOST
+ + DomainReputationClassifier.BONUS_INTERFACE_CANDIDATE,
+ attribution.confidence(), 0.0001f);
+ assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("饭太硬")));
+ }
+
+ @Test
+ public void abstainsWithoutBaselineToAvoidFirstPlayMisjudgement() {
+ // 首次播放该站,没有任何基线数据:不能断言域名异常
+ DomainReputationClassifier.Input input = DomainReputationClassifier.Input.empty();
+
+ assertNull(DomainReputationClassifier.classify(evidenceWith("ad-cdn.other.com"), input));
+ }
+
+ @Test
+ public void abstainsWhenAllSegmentsBelongToPlaylistHost() {
+ DomainReputationClassifier.Input input = new DomainReputationClassifier.Input(
+ List.of(), List.of(PLAYLIST_HOST), List.of(), "");
+
+ assertNull(DomainReputationClassifier.classify(evidenceWith(PLAYLIST_HOST), input));
+ // 子域名同样视为本站
+ assertNull(DomainReputationClassifier.classify(evidenceWith("cdn." + PLAYLIST_HOST), input));
+ }
+
+ @Test
+ public void abstainsWhenForeignHostIsInBaseline() {
+ // 该站正常使用独立 CDN 域名,已在基线内
+ DomainReputationClassifier.Input input = new DomainReputationClassifier.Input(
+ List.of(), List.of(PLAYLIST_HOST, "fast-cdn.other.com"), List.of(), "");
+
+ assertNull(DomainReputationClassifier.classify(evidenceWith("fast-cdn.other.com"), input));
+ }
+
+ @Test
+ public void handlesNullInputAndEmptyInterval() {
+ assertNull(DomainReputationClassifier.classify(null, DomainReputationClassifier.Input.empty()));
+ assertNull(DomainReputationClassifier.classify(evidenceWith("ad.other.com"), null));
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifierTest.java
new file mode 100644
index 0000000000..bee31c8d19
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifierTest.java
@@ -0,0 +1,112 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.List;
+
+public class ExistingRuleClassifierTest {
+
+ private static final String PLAYLIST_HOST = "v.example.com";
+
+ private static AdIntervalEvidence evidence(String insideHost, List removedRuleIds) {
+ return new AdIntervalEvidence(
+ 10_000, 40_000, StartOrigin.USER_MARKED,
+ "site", "站点", "剧名", "线路", "第 1 集",
+ PLAYLIST_HOST, "/play/index.m3u8", true,
+ List.of(new SegmentFact(3, insideHost, "/seg/3.ts", 6.4, true)),
+ List.of(new SegmentFact(0, PLAYLIST_HOST, "/seg/0.ts", 8.0, false)),
+ false, false, removedRuleIds, false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+ }
+
+ @Test
+ public void suggestsEnablingDisabledRuleInsteadOfCreatingNew() {
+ ExistingRuleClassifier.RuleState disabled = new ExistingRuleClassifier.RuleState(
+ "builtin:baofeng", "baofeng-preroll", "暴风片头", false, true,
+ List.of("ad-cdn.other.com"));
+
+ AdAttribution attribution = ExistingRuleClassifier.classify(
+ evidence("ad-cdn.other.com", List.of()),
+ new ExistingRuleClassifier.Input(List.of(disabled), false, List.of()));
+
+ assertNotNull(attribution);
+ assertEquals(RemediationKind.ENABLE_EXISTING_RULE, attribution.remediation());
+ assertEquals(ExistingRuleClassifier.CONFIDENCE_DISABLED_RULE,
+ attribution.confidence(), 0.0001f);
+ assertTrue(attribution.actionable());
+ assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("暴风片头")));
+ }
+
+ @Test
+ public void ignoresAlreadyEnabledRule() {
+ ExistingRuleClassifier.RuleState enabled = new ExistingRuleClassifier.RuleState(
+ "builtin:baofeng", "baofeng-preroll", "暴风片头", true, true,
+ List.of("ad-cdn.other.com"));
+
+ assertNull(ExistingRuleClassifier.classify(
+ evidence("ad-cdn.other.com", List.of()),
+ new ExistingRuleClassifier.Input(List.of(enabled), false, List.of())));
+ }
+
+ @Test
+ public void ignoresDisabledRuleThatDoesNotCoverInterval() {
+ ExistingRuleClassifier.RuleState unrelated = new ExistingRuleClassifier.RuleState(
+ "builtin:other", "other-rule", "其他规则", false, true,
+ List.of("unrelated-cdn.com"));
+
+ assertNull(ExistingRuleClassifier.classify(
+ evidence("ad-cdn.other.com", List.of()),
+ new ExistingRuleClassifier.Input(List.of(unrelated), false, List.of())));
+ }
+
+ @Test
+ public void reportsInvalidRuleAsDiagnosis() {
+ ExistingRuleClassifier.RuleState invalid = new ExistingRuleClassifier.RuleState(
+ "builtin:broken", "broken-rule", "坏规则", false, false, List.of("ad-cdn.other.com"));
+
+ AdAttribution attribution = ExistingRuleClassifier.classify(
+ evidence("ad-cdn.other.com", List.of()),
+ new ExistingRuleClassifier.Input(List.of(invalid), false, List.of()));
+
+ assertNotNull(attribution);
+ // 编译失败的规则不能建议启用,只能报诊断
+ assertEquals(RemediationKind.NONE, attribution.remediation());
+ assertEquals(AdCategory.ALREADY_HANDLED, attribution.category());
+ assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("编译失败")));
+ }
+
+ @Test
+ public void reportsLegacyHeuristicAndProtectingExcludes() {
+ AdAttribution attribution = ExistingRuleClassifier.classify(
+ evidence("ad-cdn.other.com", List.of()),
+ new ExistingRuleClassifier.Input(List.of(), true, List.of(".*/main/.*")));
+
+ assertNotNull(attribution);
+ assertEquals(RemediationKind.NONE, attribution.remediation());
+ assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("旧启发式引擎")));
+ assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("正片保护")));
+ }
+
+ @Test
+ public void reportsIntervalAlreadyRemovedByStructuredRule() {
+ AdAttribution attribution = ExistingRuleClassifier.classify(
+ evidence("ad-cdn.other.com", List.of("quantum-block")),
+ ExistingRuleClassifier.Input.empty());
+
+ assertNotNull(attribution);
+ assertTrue(attribution.evidence().stream().anyMatch(line -> line.contains("quantum-block")));
+ }
+
+ @Test
+ public void abstainsWhenNothingToReport() {
+ assertNull(ExistingRuleClassifier.classify(
+ evidence("ad-cdn.other.com", List.of()),
+ ExistingRuleClassifier.Input.empty()));
+ assertNull(ExistingRuleClassifier.classify(null, ExistingRuleClassifier.Input.empty()));
+ }
+}
From 2f1b919325f879be8f262110ff7a66ad9afb0185 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sat, 29 Aug 2026 23:03:17 +0800
Subject: [PATCH 04/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9AAdSkipCoordina?=
=?UTF-8?q?tor=20=E6=94=AF=E6=8C=81=E7=94=A8=E6=88=B7=E6=A1=86=E9=80=89?=
=?UTF-8?q?=E5=8C=BA=E9=97=B4=E7=9A=84=E5=8D=B3=E6=97=B6=E8=B7=B3=E8=BF=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第四个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 9 节。
onUserInterval() 与音频候选路径的两个关键区别:
- 区间是媒体时间,直接用作 seek 目标,不经 clock().mapCaptureToMediaMs()
二次映射(音频候选的 endTimeMs 是采集时间,必须映射)。
- 授权来自用户框选动作本身,不经 PROMPT_PENDING 确认,直接进 SEEKING。
安全校验与撤销窗口保持一致:session/generation 匹配、可 seek、非直播、
时长已知;终点超出时长则钳制;终点已播过(短按场景广告已放完)则记录
SEEK_REJECTED 并返回 false,由调用方提示「已记录,未跳过」;撤销窗口内
同一区间重复提交不再 seek。
seek 失败时恢复调用前状态而非 clearState():音频候选的 suppressedKey 与
PROMPT_PENDING 不属于本次区间反馈,连带清掉会让同一广告再次弹提示。
音频路径不受此影响(onAutoCandidate 进 seek 前已主动清 suppressedKey),
这条只在新增的用户区间路径上成立,已由测试锁定。
Verification: testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ad.* : 37 suites, 332 tests, 0 failures (AdSkipCoordinatorUserIntervalTest 15 new, AdSkipCoordinatorTest 9 unchanged)
Task-Guard: AF3-user-interval-skip
---
.../tv/ad/audio/AdSkipCoordinator.java | 60 ++++
.../AdSkipCoordinatorUserIntervalTest.java | 339 ++++++++++++++++++
2 files changed, 399 insertions(+)
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorUserIntervalTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java
index 6087965b8f..23836b5441 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinator.java
@@ -147,6 +147,66 @@ public final class AdSkipCoordinator implements AutoCloseable {
return applyAutomaticSeek(legacy, target);
}
+ /**
+ * 用户显式框选的广告区间。与 {@link #onCandidate} 的区别是授权来源:
+ * 框选动作本身即为对本次跳过的授权,因此不经 PROMPT_PENDING 直接执行,
+ * 但全部安全校验照旧,并保留撤销窗口。
+ *
+ * {@code startMs} / {@code endMs} 已经是媒体时间,不做采集时钟映射。
+ *
+ * @return 是否真的执行了 seek。终点已过或校验失败时返回 false,
+ * 调用方应把它当作「已记录,未跳过」而不是失败。
+ */
+ public synchronized boolean onUserInterval(long sessionId, long generation,
+ long startMs, long endMs, String feedbackId) {
+ Objects.requireNonNull(feedbackId, "feedbackId");
+ if (closed) return false;
+ if (endMs <= startMs || startMs < 0L) return false;
+ if (state == State.SEEKING || state == State.UNDO_WINDOW) return false;
+
+ CandidateKey key = new CandidateKey(sessionId, generation, feedbackId, startMs, endMs);
+ // 连按去重:同一区间不重复 seek
+ if (key.equals(suppressedKey) || key.equals(activeKey)) return false;
+
+ PlaybackSnapshot snapshot = playback.snapshot(sessionId, generation);
+ if (!isCurrentSeekable(snapshot, sessionId, generation)) {
+ diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED);
+ return false;
+ }
+ long target = clamp(endMs, 0L, snapshot.durationMs());
+ // 终点已经播过去了,没有可跳的内容
+ if (target <= snapshot.positionMs()) {
+ diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED);
+ return false;
+ }
+
+ long originalPositionMs = snapshot.positionMs();
+ // 失败时要还原到调用前的状态:音频候选的 suppressedKey / PROMPT_PENDING
+ // 不属于本次区间反馈,不能被连带清掉。
+ State previousState = state;
+ state = State.SEEKING;
+ SeekResult seek = playback.seekTo(sessionId, generation, target);
+ if (seek == null || !seek.applied() || seek.sessionId() != sessionId) {
+ diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED);
+ state = previousState;
+ return false;
+ }
+
+ long deadline = saturatedAdd(elapsedRealtime.getAsLong(), undoWindowMs);
+ undoContext = new UndoContext(sessionId, seek.generation(), feedbackId,
+ originalPositionMs, target, deadline);
+ activeCandidate = null;
+ activeKey = null;
+ state = State.UNDO_WINDOW;
+ long undoToken = ++actionToken;
+ AdAudioDiagnostics.log("user interval skip id=%s targetMs=%d fromMs=%d",
+ feedbackId, target, originalPositionMs);
+ ui.showUndo(new UndoPrompt(undoContext.sessionId, undoContext.generation,
+ undoContext.ruleId, undoContext.originalPositionMs,
+ undoContext.targetPositionMs, undoWindowMs), new TokenActions(undoToken));
+ return true;
+ }
+
public synchronized void reset(long sessionId) {
if (closed) return;
actionToken++;
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorUserIntervalTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorUserIntervalTest.java
new file mode 100644
index 0000000000..742b4d6851
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorUserIntervalTest.java
@@ -0,0 +1,339 @@
+package com.fongmi.android.tv.ad.audio;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.fongmi.android.tv.player.audio.PlaybackMediaClock;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.LongSupplier;
+
+/**
+ * {@link AdSkipCoordinator#onUserInterval} 的行为锁定。
+ *
+ *
与音频候选路径的关键区别:用户区间是媒体时间,不经采集时钟映射;
+ * 授权来自用户框选,不经 PROMPT_PENDING 确认。
+ */
+public class AdSkipCoordinatorUserIntervalTest {
+
+ @Test
+ public void skipsImmediatelyWithoutPromptAndKeepsUndo() {
+ ManualTime time = new ManualTime();
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 135_000L, 1_200_000L, true, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time);
+
+ boolean applied = coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1");
+
+ assertTrue(applied);
+ // 不经确认对话框
+ assertEquals(0, ui.candidateShows);
+ assertEquals(1, ui.undoShows);
+ assertEquals(List.of(165_000L), playback.seekTargets);
+ assertEquals(AdSkipCoordinator.State.UNDO_WINDOW, coordinator.state());
+ }
+
+ @Test
+ public void undoReturnsToPositionBeforeSkip() {
+ ManualTime time = new ManualTime();
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 135_000L, 1_200_000L, true, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time);
+
+ coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1");
+ ui.actions.undo();
+
+ assertEquals(List.of(165_000L, 135_000L), playback.seekTargets);
+ assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state());
+ }
+
+ @Test
+ public void usesMediaTimeDirectlyWithoutCaptureClockMapping() {
+ // 采集时钟严重滞后于播放位置。若误走 mapCaptureToMediaMs 会得到别的目标值。
+ ManualTime time = new ManualTime();
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 1L, 1L, 100_000L, 600_000L, true, false,
+ new PlaybackMediaClock.Snapshot(1L, 0L, 20_000L, 15_000L, true, true)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time);
+
+ assertTrue(coordinator.onUserInterval(1L, 1L, 100_000L, 130_000L, "fb-1"));
+
+ // 终点原样使用
+ assertEquals(List.of(130_000L), playback.seekTargets);
+ }
+
+ @Test
+ public void staleGenerationIsRejected() {
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 3L, 10_000L, 600_000L, true, false, freshClock(3L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+
+ assertFalse(coordinator.onUserInterval(7L, 2L, 10_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+ assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state());
+ }
+
+ @Test
+ public void mismatchedSessionIsRejected() {
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 10_000L, 600_000L, true, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+
+ assertFalse(coordinator.onUserInterval(8L, 2L, 10_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+ }
+
+ @Test
+ public void livePlaybackIsRejected() {
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 10_000L, 600_000L, true, true, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+
+ assertFalse(coordinator.onUserInterval(7L, 2L, 10_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+ }
+
+ @Test
+ public void unseekablePlaybackIsRejected() {
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 10_000L, 600_000L, false, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+
+ assertFalse(coordinator.onUserInterval(7L, 2L, 10_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+ }
+
+ @Test
+ public void intervalAlreadyPlayedPastIsRecordedButNotSkipped() {
+ // 短按场景:广告已经放完,当前位置在区间终点之后
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 200_000L, 600_000L, true, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+
+ assertFalse(coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+ assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state());
+ }
+
+ @Test
+ public void endBeyondDurationIsClamped() {
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 580_000L, 600_000L, true, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+
+ assertTrue(coordinator.onUserInterval(7L, 2L, 580_000L, 900_000L, "fb-1"));
+
+ assertEquals(List.of(600_000L), playback.seekTargets);
+ }
+
+ @Test
+ public void duplicateSubmissionDoesNotSeekTwice() {
+ ManualTime time = new ManualTime();
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 135_000L, 1_200_000L, true, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time);
+
+ assertTrue(coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1"));
+ // 撤销窗口内连按第二次
+ assertFalse(coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1"));
+
+ assertEquals(List.of(165_000L), playback.seekTargets);
+ }
+
+ @Test
+ public void invalidIntervalIsRejected() {
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 10_000L, 600_000L, true, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+
+ // 终点不晚于起点
+ assertFalse(coordinator.onUserInterval(7L, 2L, 40_000L, 40_000L, "fb-1"));
+ assertFalse(coordinator.onUserInterval(7L, 2L, 50_000L, 40_000L, "fb-1"));
+ // 负起点
+ assertFalse(coordinator.onUserInterval(7L, 2L, -1_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+ }
+
+ @Test
+ public void closedCoordinatorRejectsInterval() {
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 10_000L, 600_000L, true, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+ coordinator.close();
+
+ assertFalse(coordinator.onUserInterval(7L, 2L, 10_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+ }
+
+ @Test
+ public void undoAfterDeadlineDoesNotSeekBack() {
+ ManualTime time = new ManualTime();
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 7L, 2L, 135_000L, 1_200_000L, true, false, freshClock(2L)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time);
+
+ coordinator.onUserInterval(7L, 2L, 135_000L, 165_000L, "fb-1");
+ time.now = 5_001L;
+ ui.actions.undo();
+
+ assertEquals(List.of(165_000L), playback.seekTargets);
+ assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state());
+ }
+
+ @Test
+ public void audioCandidatePromptIsNotDisturbedByRejectedInterval() {
+ // 区间被拒不应清掉正在展示的音频候选提示
+ FakePlaybackPort playback = new FakePlaybackPort(snapshot(
+ 1L, 1L, 5_000L, 100_000L, true, false,
+ new PlaybackMediaClock.Snapshot(1L, 0L, 30_000L, 5_000L, true, true)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+
+ coordinator.onCandidate(new AdAudioConsumer.Candidate(1L, 1L,
+ new AudioFingerprintMatcher.MatchEvent(
+ AudioFingerprintMatcher.Type.START_MATCHED, "ad-1", 0L, 10_000L, 0.9f, 6)));
+ assertEquals(AdSkipCoordinator.State.PROMPT_PENDING, coordinator.state());
+
+ // 用无效区间触发拒绝
+ assertFalse(coordinator.onUserInterval(1L, 1L, 40_000L, 40_000L, "fb-1"));
+
+ assertEquals(AdSkipCoordinator.State.PROMPT_PENDING, coordinator.state());
+ assertEquals("ad-1", ui.lastPrompt.ruleId());
+ }
+
+ @Test
+ public void failedSeekDoesNotClearSuppressedAudioCandidate() {
+ // 音频候选被 ignore 后,其 suppressedKey 必须存活;
+ // 用户区间 seek 失败不应把它一起清掉,否则同一广告会再次弹提示。
+ RejectingSeekPort playback = new RejectingSeekPort(snapshot(
+ 1L, 1L, 5_000L, 100_000L, true, false,
+ new PlaybackMediaClock.Snapshot(1L, 0L, 30_000L, 5_000L, true, true)));
+ FakeUiPort ui = new FakeUiPort();
+ AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L);
+
+ AdAudioConsumer.Candidate audio = new AdAudioConsumer.Candidate(1L, 1L,
+ new AudioFingerprintMatcher.MatchEvent(
+ AudioFingerprintMatcher.Type.START_MATCHED, "ad-1", 0L, 10_000L, 0.9f, 6));
+ coordinator.onCandidate(audio);
+ ui.actions.ignore();
+ assertEquals(AdSkipCoordinator.State.SUPPRESSED, coordinator.state());
+
+ // 用户区间通过校验但 seekTo 被拒
+ assertFalse(coordinator.onUserInterval(1L, 1L, 5_000L, 40_000L, "fb-1"));
+
+ // 同一音频候选不得再次弹出
+ int showsBefore = ui.candidateShows;
+ coordinator.onCandidate(audio);
+ assertEquals(showsBefore, ui.candidateShows);
+ }
+
+ private static AdSkipCoordinator.PlaybackSnapshot snapshot(
+ long session, long generation, long position, long duration,
+ boolean seekable, boolean live, PlaybackMediaClock.Snapshot clock) {
+ return new AdSkipCoordinator.PlaybackSnapshot(
+ session, generation, position, duration, seekable, live, clock);
+ }
+
+ private static PlaybackMediaClock.Snapshot freshClock(long generation) {
+ return new PlaybackMediaClock.Snapshot(generation, 0L, 0L, 0L, true, true);
+ }
+
+ private static final class ManualTime implements LongSupplier {
+ long now;
+
+ @Override
+ public long getAsLong() {
+ return now;
+ }
+ }
+
+ private static final class FakePlaybackPort implements AdSkipCoordinator.PlaybackPort {
+ private AdSkipCoordinator.PlaybackSnapshot snapshot;
+ private final List seekTargets = new ArrayList<>();
+
+ FakePlaybackPort(AdSkipCoordinator.PlaybackSnapshot snapshot) {
+ this.snapshot = snapshot;
+ }
+
+ @Override
+ public AdSkipCoordinator.PlaybackSnapshot snapshot(long sessionId, long generation) {
+ return snapshot;
+ }
+
+ @Override
+ public AdSkipCoordinator.SeekResult seekTo(long sessionId, long generation, long positionMs) {
+ if (snapshot.sessionId() != sessionId || snapshot.generation() != generation) {
+ return AdSkipCoordinator.SeekResult.rejected(sessionId, generation);
+ }
+ seekTargets.add(positionMs);
+ long nextGeneration = generation == Long.MAX_VALUE ? 0L : generation + 1L;
+ PlaybackMediaClock.Snapshot nextClock = new PlaybackMediaClock.Snapshot(
+ nextGeneration, snapshot.clock().mediaAnchorMs(), snapshot.clock().capturedUntilMs(),
+ snapshot.clock().presentedCaptureMs(), snapshot.clock().playing(), snapshot.clock().fresh());
+ snapshot = new AdSkipCoordinator.PlaybackSnapshot(
+ snapshot.sessionId(), nextGeneration, positionMs, snapshot.durationMs(),
+ snapshot.seekable(), snapshot.live(), nextClock);
+ return new AdSkipCoordinator.SeekResult(true, sessionId, nextGeneration);
+ }
+ }
+
+ /** 快照校验通过,但 seekTo 一律拒绝。 */
+ private static final class RejectingSeekPort implements AdSkipCoordinator.PlaybackPort {
+ private final AdSkipCoordinator.PlaybackSnapshot snapshot;
+
+ RejectingSeekPort(AdSkipCoordinator.PlaybackSnapshot snapshot) {
+ this.snapshot = snapshot;
+ }
+
+ @Override
+ public AdSkipCoordinator.PlaybackSnapshot snapshot(long sessionId, long generation) {
+ return snapshot;
+ }
+
+ @Override
+ public AdSkipCoordinator.SeekResult seekTo(long sessionId, long generation, long positionMs) {
+ return AdSkipCoordinator.SeekResult.rejected(sessionId, generation);
+ }
+ }
+
+ private static final class FakeUiPort implements AdSkipCoordinator.UiPort {
+ private int candidateShows;
+ private int undoShows;
+ private AdSkipCoordinator.Prompt lastPrompt;
+ private AdSkipCoordinator.Actions actions;
+
+ @Override
+ public void showCandidate(AdSkipCoordinator.Prompt prompt, AdSkipCoordinator.Actions actions) {
+ candidateShows++;
+ lastPrompt = prompt;
+ this.actions = actions;
+ }
+
+ @Override
+ public void showUndo(AdSkipCoordinator.UndoPrompt prompt, AdSkipCoordinator.Actions actions) {
+ undoShows++;
+ this.actions = actions;
+ }
+
+ @Override
+ public void dismiss(long sessionId) {
+ }
+ }
+}
From 7c843fd05fba3a28f6f741cee04028b48e895671 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sat, 29 Aug 2026 23:13:17 +0800
Subject: [PATCH 05/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E7=AB=99?=
=?UTF-8?q?=E7=82=B9=E5=9F=9F=E5=90=8D=E5=9F=BA=E7=BA=BF=E3=80=81=E5=8F=8D?=
=?UTF-8?q?=E9=A6=88=E4=BC=9A=E8=AF=9D=E7=8A=B6=E6=80=81=E4=B8=8E=E8=AF=8A?=
=?UTF-8?q?=E6=96=AD=E8=AE=A1=E6=95=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第五个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 8.3、11 节。
- SitePlaylistHostBaseline:每站点最近 8 个域名、全局 200 站点的双层 LRU,
供 DomainReputationClassifier 判断「非当前适配的域名」。只存 host 字符串,
不存 URL 全文、query 或 token。持久化经 Storage 接口注入,紧凑文本格式
而非 JSON,避免依赖 Gson 以便纯 JVM 单测;siteKey 中的分隔符做转义。
- AdFeedbackSession:驱动对话框两屏的状态,withVerdict() 从第一屏推进到第二屏。
- AdFeedbackDiagnostics:12 个固定枚举计数,沿用 AdAudioDiagnostics 的
「不记录 URL、规则正文、Cookie、Authorization」约定。
修了一处 record() 的缺陷:全部输入为空白时 computeIfAbsent 会留下空条目
白占站点配额,现改为移除。尚未接线到播放器,无行为变化。
Verification: testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ad.feedback.* : 59 tests (10 new baseline), 0 failures, 0 errors
Task-Guard: AF4a-feedback-controller
---
.../tv/ad/feedback/AdFeedbackDiagnostics.java | 67 ++++++++
.../tv/ad/feedback/AdFeedbackSession.java | 55 +++++++
.../ad/feedback/SitePlaylistHostBaseline.java | 150 ++++++++++++++++++
.../SitePlaylistHostBaselineTest.java | 140 ++++++++++++++++
4 files changed, 412 insertions(+)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaselineTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java
new file mode 100644
index 0000000000..b468ae3669
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDiagnostics.java
@@ -0,0 +1,67 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import com.github.catvod.crawler.SpiderDebug;
+
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * 区间反馈的本地诊断计数。只暴露固定枚举计数,不记录媒体 URL、规则正文、
+ * Cookie、Authorization 或用户标识 —— 与 {@code AdAudioDiagnostics} 同一约定。
+ */
+public final class AdFeedbackDiagnostics {
+
+ static final String LOG_TAG = "ad-feedback";
+
+ public enum Code {
+ INTERVAL_SUBMITTED,
+ START_INFERRED_FROM_DISCONTINUITY,
+ START_INFERRED_FROM_FALLBACK,
+ IMMEDIATE_SKIP_APPLIED,
+ IMMEDIATE_SKIP_REJECTED,
+ EVIDENCE_COLLECT_FAILED,
+ CHANNEL_ABSTAINED,
+ ARBITER_NO_PLAN,
+ PLAN_ACCEPTED,
+ PLAN_DISCARDED,
+ DUPLICATE_SUBMISSION,
+ ALREADY_HANDLED_DETECTED
+ }
+
+ private final EnumMap counts = new EnumMap<>(Code.class);
+ private Code lastCode;
+
+ public void record(Code code) {
+ if (code == null) return;
+ synchronized (this) {
+ counts.put(code, counts.getOrDefault(code, 0L) + 1L);
+ lastCode = code;
+ }
+ }
+
+ /** 把起点来源折算成对应的诊断码。 */
+ public void recordStartOrigin(StartOrigin origin) {
+ if (origin == StartOrigin.DISCONTINUITY) {
+ record(Code.START_INFERRED_FROM_DISCONTINUITY);
+ } else if (origin == StartOrigin.FALLBACK_WINDOW) {
+ record(Code.START_INFERRED_FROM_FALLBACK);
+ }
+ }
+
+ public synchronized long count(Code code) {
+ return counts.getOrDefault(code, 0L);
+ }
+
+ public synchronized Code lastCode() {
+ return lastCode;
+ }
+
+ public synchronized Map snapshot() {
+ return Collections.unmodifiableMap(new EnumMap<>(counts));
+ }
+
+ static void log(String message, Object... args) {
+ SpiderDebug.log(LOG_TAG + " " + String.format(message, args));
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java
new file mode 100644
index 0000000000..b0e9f65254
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackSession.java
@@ -0,0 +1,55 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.List;
+
+/**
+ * 一次区间反馈的完整状态,驱动对话框两屏展示。
+ *
+ * @param feedbackId 去重用的稳定标识
+ * @param startMs 区间起点
+ * @param endMs 区间终点
+ * @param startOrigin 起点来源,第一屏需明示
+ * @param skipApplied 本次播放是否已跳过
+ * @param verdict 归因仲裁结果,第二屏内容;尚未完成时为 null
+ */
+public record AdFeedbackSession(String feedbackId, long startMs, long endMs,
+ StartOrigin startOrigin, boolean skipApplied,
+ AdAttributionArbiter.Verdict verdict) {
+
+ public AdFeedbackSession {
+ if (feedbackId == null || feedbackId.isBlank()) {
+ throw new IllegalArgumentException("feedbackId is required");
+ }
+ startOrigin = startOrigin == null ? StartOrigin.FALLBACK_WINDOW : startOrigin;
+ }
+
+ /** 第一屏:区间已确定,归因未开始。 */
+ public static AdFeedbackSession pending(String feedbackId, long startMs, long endMs,
+ StartOrigin startOrigin, boolean skipApplied) {
+ return new AdFeedbackSession(feedbackId, startMs, endMs, startOrigin, skipApplied, null);
+ }
+
+ /** 第二屏:附加归因结果。 */
+ public AdFeedbackSession withVerdict(AdAttributionArbiter.Verdict verdict) {
+ return new AdFeedbackSession(feedbackId, startMs, endMs, startOrigin, skipApplied, verdict);
+ }
+
+ public long durationMs() {
+ return endMs - startMs;
+ }
+
+ public boolean analysisComplete() {
+ return verdict != null;
+ }
+
+ /** 有可落地的规则方案。 */
+ public boolean hasActionablePlan() {
+ return verdict != null && verdict.hasActionablePlan();
+ }
+
+ /** 首选方案的证据,无结论时为空。 */
+ public List preferredEvidence() {
+ if (verdict == null || verdict.preferred() == null) return List.of();
+ return verdict.preferred().evidence();
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java
new file mode 100644
index 0000000000..21fd6ccabf
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaseline.java
@@ -0,0 +1,150 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * 每站点最近成功播放的域名基线,供 {@link DomainReputationClassifier} 判断
+ * 「非当前适配的域名」。见设计文档第 8.3 节。
+ *
+ * 只存 host 字符串,不存 URL 全文、query 或 token。单站点上限 8 个域名,
+ * 全局上限 200 个站点,均按最久未用淘汰。
+ *
+ *
本类只负责内存中的 LRU 结构与序列化,持久化由调用方注入的
+ * {@link Storage} 完成,因此可在纯 JVM 下单测。
+ */
+public final class SitePlaylistHostBaseline {
+
+ /** 单站点保留的域名数上限。 */
+ public static final int MAX_HOSTS_PER_SITE = 8;
+ /** 全局保留的站点数上限。 */
+ public static final int MAX_SITES = 200;
+
+ /** 持久化后端,实现通常包装 SharedPreferences。 */
+ public interface Storage {
+ String read();
+
+ void write(String value);
+ }
+
+ /** 内存实现,测试与无持久化场景使用。 */
+ public static final class MemoryStorage implements Storage {
+ private String value = "";
+
+ @Override
+ public String read() {
+ return value;
+ }
+
+ @Override
+ public void write(String value) {
+ this.value = value == null ? "" : value;
+ }
+ }
+
+ private final Storage storage;
+ // accessOrder=true:get 也算一次使用,实现真正的 LRU
+ private final LinkedHashMap> sites =
+ new LinkedHashMap<>(16, 0.75f, true);
+
+ public SitePlaylistHostBaseline(Storage storage) {
+ this.storage = storage == null ? new MemoryStorage() : storage;
+ load();
+ }
+
+ /** 记录一次成功播放涉及的域名。 */
+ public synchronized void record(String siteKey, String... hosts) {
+ if (siteKey == null || siteKey.isBlank() || hosts == null) return;
+ List known = sites.computeIfAbsent(siteKey, key -> new ArrayList<>());
+ boolean changed = false;
+ for (String host : hosts) {
+ if (host == null || host.isBlank()) continue;
+ String normalized = host.toLowerCase(Locale.US).trim();
+ // 已存在则移到末尾,表示最近使用
+ known.remove(normalized);
+ known.add(normalized);
+ changed = true;
+ }
+ if (!changed) {
+ // 全部输入为空白:不能留下空条目占用站点配额
+ if (known.isEmpty()) sites.remove(siteKey);
+ return;
+ }
+ while (known.size() > MAX_HOSTS_PER_SITE) known.remove(0);
+ evictSites();
+ persist();
+ }
+
+ /** 某站点已知的域名,最近使用的在后。 */
+ public synchronized List hosts(String siteKey) {
+ if (siteKey == null) return List.of();
+ List known = sites.get(siteKey);
+ return known == null ? List.of() : List.copyOf(known);
+ }
+
+ public synchronized int siteCount() {
+ return sites.size();
+ }
+
+ public synchronized void clear() {
+ sites.clear();
+ persist();
+ }
+
+ private void evictSites() {
+ // LinkedHashMap 的迭代顺序即访问顺序,最久未用在前
+ while (sites.size() > MAX_SITES) {
+ String oldest = sites.keySet().iterator().next();
+ sites.remove(oldest);
+ }
+ }
+
+ /**
+ * 序列化为紧凑文本:{@code siteKey|host1,host2;siteKey2|host3}。
+ * 不用 JSON 是为了避免依赖 Gson,本类需在纯 JVM 下可测。
+ */
+ private void persist() {
+ StringBuilder builder = new StringBuilder();
+ for (Map.Entry> entry : sites.entrySet()) {
+ if (entry.getValue().isEmpty()) continue;
+ if (builder.length() > 0) builder.append(';');
+ builder.append(escape(entry.getKey())).append('|');
+ builder.append(String.join(",", entry.getValue()));
+ }
+ storage.write(builder.toString());
+ }
+
+ private void load() {
+ String raw = storage.read();
+ if (raw == null || raw.isBlank()) return;
+ for (String chunk : raw.split(";")) {
+ int separator = chunk.indexOf('|');
+ if (separator <= 0) continue;
+ String siteKey = unescape(chunk.substring(0, separator));
+ String hostList = chunk.substring(separator + 1);
+ if (siteKey.isBlank() || hostList.isBlank()) continue;
+ List hosts = new ArrayList<>();
+ for (String host : hostList.split(",")) {
+ if (!host.isBlank()) hosts.add(host.trim());
+ }
+ if (hosts.isEmpty()) continue;
+ while (hosts.size() > MAX_HOSTS_PER_SITE) hosts.remove(0);
+ sites.put(siteKey, hosts);
+ }
+ evictSites();
+ }
+
+ /** siteKey 可能含分隔符,转义避免破坏格式。 */
+ private static String escape(String value) {
+ return value.replace("%", "%25").replace(";", "%3B")
+ .replace("|", "%7C").replace(",", "%2C");
+ }
+
+ private static String unescape(String value) {
+ return value.replace("%2C", ",").replace("%7C", "|")
+ .replace("%3B", ";").replace("%25", "%");
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaselineTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaselineTest.java
new file mode 100644
index 0000000000..e1b2bc828b
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/SitePlaylistHostBaselineTest.java
@@ -0,0 +1,140 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.List;
+
+public class SitePlaylistHostBaselineTest {
+
+ @Test
+ public void recordsAndReadsBackHosts() {
+ SitePlaylistHostBaseline baseline =
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage());
+
+ baseline.record("siteA", "v.example.com", "cdn.example.com");
+
+ assertEquals(List.of("v.example.com", "cdn.example.com"), baseline.hosts("siteA"));
+ }
+
+ @Test
+ public void normalisesHostCaseAndWhitespace() {
+ SitePlaylistHostBaseline baseline =
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage());
+
+ baseline.record("siteA", " V.Example.COM ");
+
+ assertEquals(List.of("v.example.com"), baseline.hosts("siteA"));
+ }
+
+ @Test
+ public void repeatedHostMovesToMostRecentWithoutDuplicating() {
+ SitePlaylistHostBaseline baseline =
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage());
+
+ baseline.record("siteA", "a.com", "b.com");
+ baseline.record("siteA", "a.com");
+
+ assertEquals(List.of("b.com", "a.com"), baseline.hosts("siteA"));
+ }
+
+ @Test
+ public void evictsOldestHostBeyondPerSiteLimit() {
+ SitePlaylistHostBaseline baseline =
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage());
+
+ for (int i = 0; i < SitePlaylistHostBaseline.MAX_HOSTS_PER_SITE + 3; i++) {
+ baseline.record("siteA", "host" + i + ".com");
+ }
+
+ List hosts = baseline.hosts("siteA");
+ assertEquals(SitePlaylistHostBaseline.MAX_HOSTS_PER_SITE, hosts.size());
+ // 最早的三个被淘汰
+ assertTrue(hosts.contains("host3.com"));
+ assertTrue(hosts.stream().noneMatch(host -> host.equals("host0.com")));
+ }
+
+ @Test
+ public void evictsLeastRecentlyUsedSiteBeyondGlobalLimit() {
+ SitePlaylistHostBaseline baseline =
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage());
+
+ for (int i = 0; i < SitePlaylistHostBaseline.MAX_SITES; i++) {
+ baseline.record("site" + i, "host.com");
+ }
+ // 触碰 site0,使其变为最近使用
+ baseline.hosts("site0");
+ baseline.record("overflow", "host.com");
+
+ assertEquals(SitePlaylistHostBaseline.MAX_SITES, baseline.siteCount());
+ // site0 因刚被访问而存活,site1 成为最久未用被淘汰
+ assertTrue(baseline.hosts("site0").contains("host.com"));
+ assertTrue(baseline.hosts("site1").isEmpty());
+ }
+
+ @Test
+ public void survivesRoundTripThroughStorage() {
+ SitePlaylistHostBaseline.MemoryStorage storage = new SitePlaylistHostBaseline.MemoryStorage();
+ SitePlaylistHostBaseline first = new SitePlaylistHostBaseline(storage);
+ first.record("siteA", "a.com", "b.com");
+ first.record("siteB", "c.com");
+
+ SitePlaylistHostBaseline restored = new SitePlaylistHostBaseline(storage);
+
+ assertEquals(List.of("a.com", "b.com"), restored.hosts("siteA"));
+ assertEquals(List.of("c.com"), restored.hosts("siteB"));
+ }
+
+ @Test
+ public void escapesSiteKeyContainingSeparators() {
+ SitePlaylistHostBaseline.MemoryStorage storage = new SitePlaylistHostBaseline.MemoryStorage();
+ SitePlaylistHostBaseline first = new SitePlaylistHostBaseline(storage);
+ String awkward = "site;with|odd,chars%here";
+ first.record(awkward, "a.com");
+
+ SitePlaylistHostBaseline restored = new SitePlaylistHostBaseline(storage);
+
+ assertEquals(List.of("a.com"), restored.hosts(awkward));
+ assertEquals(1, restored.siteCount());
+ }
+
+ @Test
+ public void ignoresBlankAndNullInput() {
+ SitePlaylistHostBaseline baseline =
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage());
+
+ baseline.record(null, "a.com");
+ baseline.record(" ", "a.com");
+ baseline.record("siteA", (String[]) null);
+ baseline.record("siteA", "", " ");
+
+ assertEquals(0, baseline.siteCount());
+ assertTrue(baseline.hosts("siteA").isEmpty());
+ assertTrue(baseline.hosts(null).isEmpty());
+ }
+
+ @Test
+ public void toleratesCorruptStoredValue() {
+ SitePlaylistHostBaseline.MemoryStorage storage = new SitePlaylistHostBaseline.MemoryStorage();
+ storage.write("garbage;;|;siteA|a.com;noseparator");
+
+ SitePlaylistHostBaseline baseline = new SitePlaylistHostBaseline(storage);
+
+ assertEquals(List.of("a.com"), baseline.hosts("siteA"));
+ assertEquals(1, baseline.siteCount());
+ }
+
+ @Test
+ public void clearRemovesEverythingAndPersists() {
+ SitePlaylistHostBaseline.MemoryStorage storage = new SitePlaylistHostBaseline.MemoryStorage();
+ SitePlaylistHostBaseline baseline = new SitePlaylistHostBaseline(storage);
+ baseline.record("siteA", "a.com");
+
+ baseline.clear();
+
+ assertEquals(0, baseline.siteCount());
+ assertEquals(0, new SitePlaylistHostBaseline(storage).siteCount());
+ }
+}
From d8ef2aed1f7fa6b6e6887af6d4175bc844b3bbd0 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sat, 29 Aug 2026 23:41:53 +0800
Subject: [PATCH 06/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=8C=BA?=
=?UTF-8?q?=E9=97=B4=E8=B7=B3=E8=BF=87=E7=9A=84=E8=BF=90=E8=A1=8C=E6=97=B6?=
=?UTF-8?q?=E6=8E=A5=E7=BA=BF=EF=BC=8C=E4=B8=8D=E4=BE=9D=E8=B5=96=E9=9F=B3?=
=?UTF-8?q?=E9=A2=91=E6=8C=87=E7=BA=B9=E5=BC=80=E5=85=B3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第六个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 9 节。
- AdAudioRuntimeController.skipUserInterval():UI 已绑定但 coordinator 尚未
建立时按需创建。bindUi() 只在音频通道启用路径上被调用,不能作为区间跳过
的前置条件 —— 指纹与语音都关闭、规则库为空时,用户框选广告仍须能跳过。
- PlayerManager.skipUserAdInterval():对外入口,isReleased() 时安全返回 false。
区间反馈单独跟踪 intervalSessionId/intervalGeneration 并在提交前对齐
coordinator:timeline reset 只经 PCM provider 的回调转发,音频通道关闭时
那条路径不存在,coordinator 会永远卡在上一次跳过留下的 UNDO_WINDOW 上导致
后续每次提交都被拒。基线取 seek 之后的实际状态,因为 seek 会推进 generation。
刻意不复用 activeSessionId:该字段决定音频管线是否需要重建,从区间路径改写
它会让 refreshLocked 漏掉一次必要的重建。
Verification: testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.ad.* : 350 tests, 0 failures (AdAudioRuntimeControllerUserIntervalTest 8 new); compileMobileArm64_v8aDebugJavaWithJavac OK
Task-Guard: AF4b-skip-plumbing
---
.../tv/ad/audio/AdAudioRuntimeController.java | 45 ++++
.../android/tv/player/PlayerManager.java | 10 +
...udioRuntimeControllerUserIntervalTest.java | 232 ++++++++++++++++++
3 files changed, 287 insertions(+)
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerUserIntervalTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java
index ff80383daa..11a61706f8 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeController.java
@@ -61,6 +61,9 @@ public final class AdAudioRuntimeController implements AutoCloseable {
private boolean enabled;
private String lastRefreshLog = "";
private long activeSessionId = Long.MIN_VALUE;
+ /** 区间反馈单独跟踪 session/generation,避免与音频管线的重建判定耦合。 */
+ private long intervalSessionId = Long.MIN_VALUE;
+ private long intervalGeneration = Long.MIN_VALUE;
private boolean closed;
public AdAudioRuntimeController(PlaybackMediaSignalHub hub, PlaybackMediaClock clock,
@@ -172,12 +175,14 @@ public final class AdAudioRuntimeController implements AutoCloseable {
if (coordinator != null) coordinator.close();
this.ui = ui;
this.coordinator = new AdSkipCoordinator(playback, ui, 5_000L, diagnostics);
+ resetIntervalBaseline();
refreshLocked();
}
public synchronized void unbindUi() {
if (coordinator != null) coordinator.close();
coordinator = null;
+ resetIntervalBaseline();
ui = null;
deactivateLocked();
}
@@ -207,6 +212,39 @@ public final class AdAudioRuntimeController implements AutoCloseable {
return snapshot;
}
+ /**
+ * 执行用户框选区间的即时跳过。
+ *
+ * 区间反馈不依赖音频指纹功能:即使指纹与语音通道都关闭、没有任何规则,
+ * 用户依然可以框选广告并要求跳过。因此这里在 UI 已绑定但 coordinator
+ * 尚未建立时按需创建一个 —— {@link #bindUi} 只在音频通道启用路径上被调用,
+ * 不能作为区间跳过的前置条件。
+ *
+ * @return 是否真的执行了 seek;未绑定 UI 或校验失败时为 false
+ */
+ public synchronized boolean skipUserInterval(long startMs, long endMs, String feedbackId) {
+ if (closed || ui == null) return false;
+ PlaybackMediaSignalHub.Session session = hub.session();
+ if (coordinator == null) {
+ coordinator = new AdSkipCoordinator(playback, ui, 5_000L, diagnostics);
+ } else if (intervalSessionId != Long.MIN_VALUE
+ && (intervalSessionId != session.id() || intervalGeneration != session.generation())) {
+ // timeline reset 只经 PCM provider 的回调转发给 coordinator。音频通道关闭时
+ // 那条路径不存在,coordinator 会永远卡在上一次跳过留下的 UNDO_WINDOW 上,
+ // 之后每一次区间提交都被拒。这里按播放实际状态主动对齐。
+ // 不复用 activeSessionId:那个字段决定音频管线是否需要重建,
+ // 从这条路径改写它会让 refreshLocked 漏掉一次必要的重建。
+ coordinator.reset(intervalSessionId);
+ }
+ boolean applied = coordinator.onUserInterval(session.id(), session.generation(),
+ startMs, endMs, feedbackId);
+ // seek 会推进 generation,基线取调用后的实际状态
+ PlaybackMediaSignalHub.Session current = hub.session();
+ intervalSessionId = current.id();
+ intervalGeneration = current.generation();
+ return applied;
+ }
+
public synchronized AdSkipPolicyController.Mode skipMode() {
return skipMode;
}
@@ -236,6 +274,7 @@ public final class AdAudioRuntimeController implements AutoCloseable {
deactivateLocked();
if (coordinator != null) coordinator.close();
coordinator = null;
+ resetIntervalBaseline();
ui = null;
}
@@ -246,10 +285,16 @@ public final class AdAudioRuntimeController implements AutoCloseable {
deactivateLocked();
if (coordinator != null) coordinator.close();
coordinator = null;
+ resetIntervalBaseline();
ui = null;
if (workerShutdown != null) workerShutdown.run();
}
+ private void resetIntervalBaseline() {
+ intervalSessionId = Long.MIN_VALUE;
+ intervalGeneration = Long.MIN_VALUE;
+ }
+
private void loadRulesLocked() {
try {
AdAudioRuleSnapshot loaded = ruleSource.load();
diff --git a/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java b/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java
index f376bbd4e2..8efa519584 100644
--- a/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java
+++ b/app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java
@@ -744,6 +744,16 @@ public class PlayerManager implements ParseCallback {
adAudioRuntime.unbindUi();
}
+ /**
+ * 跳过用户框选的广告区间。区间为媒体时间,不依赖音频指纹功能开关。
+ *
+ * @return 是否真的执行了 seek。false 表示已记录但未跳过(如终点已播过)。
+ */
+ public boolean skipUserAdInterval(long startMs, long endMs, String feedbackId) {
+ if (isReleased()) return false;
+ return adAudioRuntime.skipUserInterval(startMs, endMs, feedbackId);
+ }
+
public void reloadAdAudioRules() {
reloadAdAudioSettings();
}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerUserIntervalTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerUserIntervalTest.java
new file mode 100644
index 0000000000..84fc8d83ae
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerUserIntervalTest.java
@@ -0,0 +1,232 @@
+package com.fongmi.android.tv.ad.audio;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.fongmi.android.tv.player.audio.PlaybackMediaClock;
+import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * {@link AdAudioRuntimeController#skipUserInterval} 的行为锁定。
+ *
+ *
核心契约:区间反馈不依赖音频指纹功能。指纹与语音都关闭、规则库为空时,
+ * 用户框选广告依然要能跳过。
+ */
+public class AdAudioRuntimeControllerUserIntervalTest {
+
+ @Test
+ public void skipsIntervalWithFingerprintDisabledAndNoRules() {
+ PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8);
+ hub.beginSession(0L);
+ FakePlaybackPort playback = new FakePlaybackPort(hub, true);
+ AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot());
+ // 功能关闭、规则为空 —— 音频通道完全不工作
+ runtime.start(false);
+ runtime.bindUi(new FakeUiPort());
+
+ assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO));
+ assertTrue(runtime.skipUserInterval(1_000L, 40_000L, "fb-1"));
+ assertEquals(List.of(40_000L), playback.seekTargets);
+
+ runtime.close();
+ }
+
+ @Test
+ public void requiresBoundUiBecauseUndoPromptHasNowhereToGo() {
+ PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8);
+ hub.beginSession(0L);
+ FakePlaybackPort playback = new FakePlaybackPort(hub, true);
+ AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot());
+ runtime.start(false);
+
+ assertFalse(runtime.skipUserInterval(1_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+
+ runtime.close();
+ }
+
+ @Test
+ public void showsUndoPromptAfterSkip() {
+ PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8);
+ hub.beginSession(0L);
+ FakePlaybackPort playback = new FakePlaybackPort(hub, true);
+ AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot());
+ FakeUiPort ui = new FakeUiPort();
+ runtime.start(false);
+ runtime.bindUi(ui);
+
+ runtime.skipUserInterval(1_000L, 40_000L, "fb-1");
+
+ assertEquals(1, ui.undoShows);
+ // 不经确认提示,用户框选本身即为授权
+ assertEquals(0, ui.candidateShows);
+
+ runtime.close();
+ }
+
+ @Test
+ public void reusesCoordinatorCreatedByBindUi() {
+ PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8);
+ hub.beginSession(0L);
+ FakePlaybackPort playback = new FakePlaybackPort(hub, true);
+ // 指纹启用:bindUi 已建立 coordinator
+ AdAudioRuntimeController runtime = runtime(hub, playback, goodSnapshot());
+ FakeUiPort ui = new FakeUiPort();
+ runtime.start(true);
+ runtime.bindUi(ui);
+
+ assertTrue(runtime.skipUserInterval(1_000L, 40_000L, "fb-1"));
+ assertEquals(List.of(40_000L), playback.seekTargets);
+
+ runtime.close();
+ }
+
+ @Test
+ public void ineligiblePlaybackIsRejected() {
+ PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8);
+ hub.beginSession(0L);
+ // eligible=false 会让快照变成 live 且不可 seek
+ FakePlaybackPort playback = new FakePlaybackPort(hub, false);
+ AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot());
+ runtime.start(false);
+ runtime.bindUi(new FakeUiPort());
+
+ assertFalse(runtime.skipUserInterval(1_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+
+ runtime.close();
+ }
+
+ @Test
+ public void closedRuntimeRejectsInterval() {
+ PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8);
+ hub.beginSession(0L);
+ FakePlaybackPort playback = new FakePlaybackPort(hub, true);
+ AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot());
+ runtime.start(false);
+ runtime.bindUi(new FakeUiPort());
+ runtime.close();
+
+ assertFalse(runtime.skipUserInterval(1_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+ }
+
+ @Test
+ public void unbindUiPreventsFurtherIntervalSkips() {
+ PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8);
+ hub.beginSession(0L);
+ FakePlaybackPort playback = new FakePlaybackPort(hub, true);
+ AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot());
+ runtime.start(false);
+ runtime.bindUi(new FakeUiPort());
+ runtime.unbindUi();
+
+ assertFalse(runtime.skipUserInterval(1_000L, 40_000L, "fb-1"));
+ assertTrue(playback.seekTargets.isEmpty());
+
+ runtime.close();
+ }
+
+ @Test
+ public void usesCurrentSessionSoStaleIntervalsCannotSeek() {
+ PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8);
+ hub.beginSession(0L);
+ FakePlaybackPort playback = new FakePlaybackPort(hub, true);
+ AdAudioRuntimeController runtime = runtime(hub, playback, emptySnapshot());
+ runtime.start(false);
+ runtime.bindUi(new FakeUiPort());
+
+ // 换源:session 变更后旧区间不应再套用到新内容上
+ assertTrue(runtime.skipUserInterval(1_000L, 40_000L, "fb-1"));
+ hub.resetTimeline(0L, PlaybackMediaSignalHub.ResetReason.SOURCE_CHANGED);
+
+ // 新 session 下同一 feedbackId 视为新的一次提交
+ assertTrue(runtime.skipUserInterval(1_000L, 50_000L, "fb-1"));
+ assertEquals(List.of(40_000L, 50_000L), playback.seekTargets);
+
+ runtime.close();
+ }
+
+ private static AdAudioRuntimeController runtime(
+ PlaybackMediaSignalHub hub, FakePlaybackPort playback, AdAudioRuleSnapshot snapshot) {
+ return new AdAudioRuntimeController(
+ hub, new PlaybackMediaClock(500L), () -> snapshot, playback,
+ Runnable::run, () -> { });
+ }
+
+ private static AdAudioRuleSnapshot emptySnapshot() {
+ return new AdAudioRuleSnapshot(
+ "test", "", AudioFingerprintRuleSet.empty(), List.of(), "");
+ }
+
+ private static AdAudioRuleSnapshot goodSnapshot() {
+ AudioFingerprintRuleSet rules = AudioFingerprintRuleCodec.fromJson("{"
+ + "\"schemaVersion\":2,\"algorithm\":{"
+ + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000,"
+ + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16},"
+ + "\"rules\":[{\"id\":\"ad\",\"durationMs\":10000,"
+ + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000,"
+ + "\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}]}");
+ return new AdAudioRuleSnapshot("test", "v1", rules, List.of(), "");
+ }
+
+ private static final class FakePlaybackPort implements AdAudioRuntimeController.PlaybackPort {
+ private final PlaybackMediaSignalHub hub;
+ private final List seekTargets = new ArrayList<>();
+ private boolean eligible;
+
+ FakePlaybackPort(PlaybackMediaSignalHub hub, boolean eligible) {
+ this.hub = hub;
+ this.eligible = eligible;
+ }
+
+ @Override
+ public boolean isEligible(long sessionId, long generation) {
+ return eligible;
+ }
+
+ @Override
+ public AdSkipCoordinator.PlaybackSnapshot snapshot(long sessionId, long generation) {
+ return new AdSkipCoordinator.PlaybackSnapshot(
+ sessionId, generation, 1_000L, 100_000L, eligible, !eligible,
+ new PlaybackMediaClock.Snapshot(generation, 0L, 50_000L, 1_000L, true, true));
+ }
+
+ @Override
+ public AdSkipCoordinator.SeekResult seekTo(long sessionId, long generation, long positionMs) {
+ PlaybackMediaSignalHub.Session session = hub.session();
+ boolean applied = eligible && session.id() == sessionId
+ && session.generation() == generation;
+ if (applied) seekTargets.add(positionMs);
+ return new AdSkipCoordinator.SeekResult(applied, session.id(), session.generation());
+ }
+ }
+
+ private static final class FakeUiPort implements AdSkipCoordinator.UiPort {
+ private int candidateShows;
+ private int undoShows;
+ private AdSkipCoordinator.Actions actions;
+
+ @Override
+ public void showCandidate(AdSkipCoordinator.Prompt prompt, AdSkipCoordinator.Actions actions) {
+ candidateShows++;
+ this.actions = actions;
+ }
+
+ @Override
+ public void showUndo(AdSkipCoordinator.UndoPrompt prompt, AdSkipCoordinator.Actions actions) {
+ undoShows++;
+ this.actions = actions;
+ }
+
+ @Override
+ public void dismiss(long sessionId) {
+ }
+ }
+}
From 2822ea1f9e78293f174d9023e1cc6cdd973b1e6c Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sat, 29 Aug 2026 23:51:45 +0800
Subject: [PATCH 07/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=8C=BA?=
=?UTF-8?q?=E9=97=B4=E5=8F=8D=E9=A6=88=E8=AF=81=E6=8D=AE=E9=87=87=E9=9B=86?=
=?UTF-8?q?=E4=B8=8E=E5=BD=92=E5=9B=A0=E7=BC=96=E6=8E=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Verification: testMobileArm64_v8aDebugUnitTest: ad.feedback 84 tests, 0 failures (AdEvidenceCollector 12, AdFeedbackController 13 new)
Task-Guard: AF4c-controller
---
.../tv/ad/feedback/AdEvidenceCollector.java | 170 ++++++++++
.../tv/ad/feedback/AdFeedbackController.java | 180 ++++++++++
.../ad/feedback/AdEvidenceCollectorTest.java | 187 ++++++++++
.../ad/feedback/AdFeedbackControllerTest.java | 321 ++++++++++++++++++
4 files changed, 858 insertions(+)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollectorTest.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java
new file mode 100644
index 0000000000..6e069f6c40
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollector.java
@@ -0,0 +1,170 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import com.fongmi.android.tv.bean.M3u8Evidence;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * 把 {@code M3u8Parser} 已产出的 playlist 证据 + 用户区间,组装成
+ * {@link AdIntervalEvidence}。纯计算,不发起网络请求,不触碰 Android 类型。
+ *
+ * 见设计文档第 6.1、7.1 节。
+ */
+public final class AdEvidenceCollector {
+
+ /** 区间外切片最多保留多少个作为对照组,避免长剧集证据膨胀。 */
+ static final int MAX_OUTSIDE_SAMPLES = 60;
+
+ private AdEvidenceCollector() {
+ }
+
+ /**
+ * 播放上下文,全部由调用方从 Activity/PlayerManager 取出,保持本类可单测。
+ *
+ * @param siteKey 站点标识
+ * @param siteName 站点名称
+ * @param vodName 剧名
+ * @param flagName 线路名
+ * @param episodeName 集名
+ * @param playUrl 播放地址,仅用于取 host/path,不入证据
+ * @param hls 是否 HLS
+ */
+ public record Context(String siteKey, String siteName, String vodName,
+ String flagName, String episodeName, String playUrl, boolean hls) {
+
+ public Context {
+ siteKey = siteKey == null ? "" : siteKey;
+ siteName = siteName == null ? "" : siteName;
+ vodName = vodName == null ? "" : vodName;
+ flagName = flagName == null ? "" : flagName;
+ episodeName = episodeName == null ? "" : episodeName;
+ playUrl = playUrl == null ? "" : playUrl;
+ }
+ }
+
+ /**
+ * 组装区间证据。
+ *
+ * @param evidence playlist 证据,可为 null(非 HLS 或抓取失败)
+ * @param blacklistedHosts 现有广告域名黑名单,用于填充 matchedExistingHosts
+ * @param legacyHeuristicActive 旧启发式引擎当次是否生效
+ */
+ public static AdIntervalEvidence collect(Context context, M3u8Evidence evidence,
+ long startMs, long endMs, StartOrigin origin,
+ List blacklistedHosts,
+ boolean legacyHeuristicActive) {
+ if (context == null) throw new IllegalArgumentException("context is required");
+ String playlistHost = hostOf(context.playUrl());
+ String urlPath = pathOf(context.playUrl());
+
+ List inside = new ArrayList<>();
+ List outside = new ArrayList<>();
+ boolean bounded = false;
+
+ if (evidence != null && !evidence.isEmpty()) {
+ List durations = evidence.getDurations();
+ List segments = evidence.getSegments();
+ Set discontinuities = new LinkedHashSet<>();
+ for (Integer index : evidence.getDiscontinuities()) {
+ if (index != null) discontinuities.add(index);
+ }
+ Set insideIndices = new LinkedHashSet<>(
+ AdIntervalMapper.insideIndices(durations, startMs, endMs));
+
+ int limit = Math.min(segments.size(), durations.size());
+ for (int i = 0; i < limit; i++) {
+ SegmentFact fact = factOf(i, segments.get(i), durations.get(i),
+ discontinuities.contains(i));
+ if (insideIndices.contains(i)) inside.add(fact);
+ else if (outside.size() < MAX_OUTSIDE_SAMPLES) outside.add(fact);
+ }
+ bounded = boundedByDiscontinuity(insideIndices, discontinuities, limit);
+ }
+
+ boolean crossDomain = !inside.isEmpty() && !playlistHost.isEmpty()
+ && inside.stream().allMatch(fact ->
+ !fact.host().isEmpty() && !fact.hostEndsWith(playlistHost));
+
+ return new AdIntervalEvidence(
+ startMs, endMs, origin,
+ context.siteKey(), context.siteName(), context.vodName(),
+ context.flagName(), context.episodeName(),
+ playlistHost, urlPath, context.hls(),
+ inside, outside, bounded, crossDomain,
+ List.of(), legacyHeuristicActive,
+ matchedHosts(inside, blacklistedHosts),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+ }
+
+ /**
+ * 区间首尾是否都贴着断点边界。首端要求区间第一个切片自身带断点,
+ * 尾端要求区间后的第一个切片带断点(即广告块结束、正片重新开始)。
+ */
+ private static boolean boundedByDiscontinuity(Set insideIndices,
+ Set discontinuities, int total) {
+ if (insideIndices.isEmpty() || discontinuities.isEmpty()) return false;
+ int first = Integer.MAX_VALUE;
+ int last = Integer.MIN_VALUE;
+ for (Integer index : insideIndices) {
+ first = Math.min(first, index);
+ last = Math.max(last, index);
+ }
+ boolean head = discontinuities.contains(first);
+ // 区间正好到 playlist 结尾时,没有「之后的切片」,视为尾端成立
+ boolean tail = last + 1 >= total || discontinuities.contains(last + 1);
+ return head && tail;
+ }
+
+ private static List matchedHosts(List inside, List blacklist) {
+ if (inside.isEmpty() || blacklist == null || blacklist.isEmpty()) return List.of();
+ Set matched = new LinkedHashSet<>();
+ for (SegmentFact fact : inside) {
+ if (fact.host().isEmpty()) continue;
+ String host = fact.host().toLowerCase(Locale.US);
+ for (String entry : blacklist) {
+ if (entry == null || entry.isBlank()) continue;
+ String target = entry.toLowerCase(Locale.US).trim();
+ if (host.equals(target) || host.endsWith("." + target) || host.contains(target)) {
+ matched.add(fact.host());
+ break;
+ }
+ }
+ }
+ return List.copyOf(matched);
+ }
+
+ private static SegmentFact factOf(int index, String url, Float duration, boolean discontinuity) {
+ double seconds = duration == null ? 0d : duration;
+ return new SegmentFact(index, hostOf(url), pathOf(url), seconds, discontinuity);
+ }
+
+ /** 取 host,解析失败返回空串。相对 URI 没有 host,属正常情况。 */
+ static String hostOf(String url) {
+ if (url == null || url.isBlank()) return "";
+ try {
+ String host = URI.create(url.trim()).getHost();
+ return host == null ? "" : host;
+ } catch (RuntimeException e) {
+ return "";
+ }
+ }
+
+ /** 取去掉 query 的 path。相对 URI 直接返回其自身去参数部分。 */
+ static String pathOf(String url) {
+ if (url == null || url.isBlank()) return "";
+ String trimmed = url.trim();
+ try {
+ String path = URI.create(trimmed).getPath();
+ if (path != null && !path.isEmpty()) return path;
+ } catch (RuntimeException ignored) {
+ // 落到下面的字符串截断
+ }
+ int query = trimmed.indexOf('?');
+ return query < 0 ? trimmed : trimmed.substring(0, query);
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java
new file mode 100644
index 0000000000..98b160adcb
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java
@@ -0,0 +1,180 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import com.fongmi.android.tv.bean.M3u8Evidence;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 区间反馈的编排器:接收区间 → 立即跳过 → 采集证据 → 多通道归因 → 仲裁 → 回调 UI。
+ *
+ * 三端(leanback / mobile / TmdbDetail)共用本类,Activity 只实现 {@link Host}。
+ * 见设计文档第 5.2 节。所有 Android 依赖都在 {@link Host} 后面,因此本类可单测。
+ */
+public final class AdFeedbackController {
+
+ /** Activity 侧需要提供的能力。 */
+ public interface Host {
+ /** 当前播放位置,毫秒。 */
+ long positionMs();
+
+ /** 总时长,毫秒;未知时返回非正数。 */
+ long durationMs();
+
+ /** 采集上下文。 */
+ AdEvidenceCollector.Context context();
+
+ /** 抓取 playlist 证据,阻塞 I/O,由 {@link #runBackground} 调度。可返回 null。 */
+ M3u8Evidence fetchEvidence();
+
+ /** 执行本次跳过,返回是否真的 seek 了。 */
+ boolean skipInterval(long startMs, long endMs, String feedbackId);
+
+ /** 现有广告域名黑名单。 */
+ List blacklistedHosts();
+
+ /** 本站历史正常域名。 */
+ List siteBaselineHosts();
+
+ /** 接口学习的待审候选域名。 */
+ List interfaceCandidateHosts();
+
+ /** 命中接口候选时展示的来源名。 */
+ String interfaceSourceName();
+
+ /** 已知 HLS 规则状态。 */
+ List hlsRuleStates();
+
+ /** 正片保护正则。 */
+ List protectingExcludes();
+
+ /** 旧启发式引擎当次是否生效。 */
+ boolean legacyHeuristicActive();
+
+ /** 音频/语音通道已有候选的起点,无则返回负数。 */
+ default long audioCandidateStartMs() {
+ return -1L;
+ }
+
+ /** 短按回溯窗口。 */
+ default long fallbackWindowMs() {
+ return AdIntervalMapper.DEFAULT_FALLBACK_WINDOW_MS;
+ }
+
+ /** 后台执行阻塞任务。 */
+ void runBackground(Runnable task);
+
+ /** 回到主线程更新 UI。 */
+ void runOnUi(Runnable task);
+
+ /** 展示或刷新反馈对话框。 */
+ void showSession(AdFeedbackSession session);
+ }
+
+ private final Host host;
+ private final AdFeedbackDiagnostics diagnostics;
+ /** 防止过期回调覆盖新一次反馈的结果。 */
+ private int generation;
+
+ public AdFeedbackController(Host host) {
+ this(host, new AdFeedbackDiagnostics());
+ }
+
+ public AdFeedbackController(Host host, AdFeedbackDiagnostics diagnostics) {
+ if (host == null) throw new IllegalArgumentException("host is required");
+ this.host = host;
+ this.diagnostics = diagnostics == null ? new AdFeedbackDiagnostics() : diagnostics;
+ }
+
+ public AdFeedbackDiagnostics diagnostics() {
+ return diagnostics;
+ }
+
+ /**
+ * 短按:以当前位置为终点,回溯推断起点。
+ *
+ * @return 本次反馈的会话,播放状态不允许时返回 null
+ */
+ public AdFeedbackSession onQuickReport(M3u8Evidence cachedEvidence) {
+ long endMs = host.positionMs();
+ if (endMs <= 0) return null;
+ AdIntervalMapper.InferredStart start = AdIntervalMapper.inferStart(
+ cachedEvidence, endMs, host.audioCandidateStartMs(), host.fallbackWindowMs());
+ return submit(start.startMs(), endMs, start.origin());
+ }
+
+ /** 长按标记模式:用户显式框选的区间。 */
+ public AdFeedbackSession onMarkedInterval(long startMs, long endMs) {
+ return submit(startMs, endMs, StartOrigin.USER_MARKED);
+ }
+
+ /**
+ * 提交一个区间:立即尝试跳过,然后在后台归因。
+ */
+ public AdFeedbackSession submit(long startMs, long endMs, StartOrigin origin) {
+ if (endMs <= startMs || startMs < 0) return null;
+ long duration = host.durationMs();
+ if (duration > 0 && startMs >= duration) return null;
+
+ String feedbackId = "fb-" + UUID.randomUUID();
+ diagnostics.record(AdFeedbackDiagnostics.Code.INTERVAL_SUBMITTED);
+ diagnostics.recordStartOrigin(origin);
+
+ boolean skipped = host.skipInterval(startMs, endMs, feedbackId);
+ diagnostics.record(skipped
+ ? AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_APPLIED
+ : AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_REJECTED);
+
+ AdFeedbackSession session = AdFeedbackSession.pending(
+ feedbackId, startMs, endMs, origin, skipped);
+ host.showSession(session);
+
+ int current = ++generation;
+ host.runBackground(() -> analyse(session, current));
+ return session;
+ }
+
+ private void analyse(AdFeedbackSession session, int expectedGeneration) {
+ AdAttributionArbiter.Verdict verdict;
+ try {
+ verdict = classify(session);
+ } catch (RuntimeException e) {
+ diagnostics.record(AdFeedbackDiagnostics.Code.EVIDENCE_COLLECT_FAILED);
+ verdict = new AdAttributionArbiter.Verdict(null, List.of(), List.of());
+ }
+ AdAttributionArbiter.Verdict result = verdict;
+ host.runOnUi(() -> {
+ if (expectedGeneration != generation) return;
+ host.showSession(session.withVerdict(result));
+ });
+ }
+
+ private AdAttributionArbiter.Verdict classify(AdFeedbackSession session) {
+ M3u8Evidence playlist = host.fetchEvidence();
+ List blacklist = host.blacklistedHosts();
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ host.context(), playlist, session.startMs(), session.endMs(),
+ session.startOrigin(), blacklist, host.legacyHeuristicActive());
+
+ List attributions = new ArrayList<>();
+ attributions.add(HlsSegmentClassifier.classify(evidence));
+ attributions.add(DomainReputationClassifier.classify(evidence,
+ new DomainReputationClassifier.Input(blacklist, host.siteBaselineHosts(),
+ host.interfaceCandidateHosts(), host.interfaceSourceName())));
+ attributions.add(ExistingRuleClassifier.classify(evidence,
+ new ExistingRuleClassifier.Input(host.hlsRuleStates(),
+ host.legacyHeuristicActive(), host.protectingExcludes())));
+
+ for (AdAttribution attribution : attributions) {
+ if (attribution == null) diagnostics.record(AdFeedbackDiagnostics.Code.CHANNEL_ABSTAINED);
+ else if (attribution.category() == AdCategory.ALREADY_HANDLED) {
+ diagnostics.record(AdFeedbackDiagnostics.Code.ALREADY_HANDLED_DETECTED);
+ }
+ }
+
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(attributions);
+ if (verdict.empty()) diagnostics.record(AdFeedbackDiagnostics.Code.ARBITER_NO_PLAN);
+ return verdict;
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollectorTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollectorTest.java
new file mode 100644
index 0000000000..5b2dc48f30
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdEvidenceCollectorTest.java
@@ -0,0 +1,187 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.fongmi.android.tv.bean.M3u8Evidence;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class AdEvidenceCollectorTest {
+
+ private static final AdEvidenceCollector.Context CONTEXT = new AdEvidenceCollector.Context(
+ "site", "站点", "剧名", "线路", "第 1 集",
+ "https://v.example.com/play/index.m3u8?token=secret", true);
+
+ /** 10 个切片,每个 10s;下标 3-5 来自广告域名。 */
+ private static M3u8Evidence tenSegments(List discontinuities) {
+ List segments = new ArrayList<>();
+ List durations = new ArrayList<>();
+ List switches = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ boolean ad = i >= 3 && i <= 5;
+ segments.add(ad
+ ? "https://ad-cdn.other.com/creative/" + i + ".ts?sid=x"
+ : "https://v.example.com/seg/" + i + ".ts");
+ durations.add(10f);
+ switches.add(ad);
+ }
+ return M3u8Evidence.create(segments, discontinuities, durations, switches);
+ }
+
+ @Test
+ public void splitsSegmentsIntoInsideAndOutside() {
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, tenSegments(List.of()), 30_000, 60_000,
+ StartOrigin.USER_MARKED, List.of(), false);
+
+ assertEquals(List.of(3, 4, 5), evidence.inside().stream().map(SegmentFact::index).toList());
+ assertEquals(7, evidence.outside().size());
+ assertTrue(evidence.hasSegmentEvidence());
+ }
+
+ @Test
+ public void stripsQueryFromPlaylistAndSegmentUrls() {
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, tenSegments(List.of()), 30_000, 60_000,
+ StartOrigin.USER_MARKED, List.of(), false);
+
+ // 播放地址的 token 不得进入证据
+ assertEquals("/play/index.m3u8", evidence.urlPath());
+ assertEquals("v.example.com", evidence.playlistHost());
+ assertEquals("/creative/3.ts", evidence.inside().get(0).path());
+ assertTrue(evidence.inside().stream().noneMatch(fact -> fact.path().contains("sid=")));
+ }
+
+ @Test
+ public void detectsCrossDomainWhenAllInsideSegmentsAreForeign() {
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, tenSegments(List.of()), 30_000, 60_000,
+ StartOrigin.USER_MARKED, List.of(), false);
+
+ assertTrue(evidence.crossDomain());
+ }
+
+ @Test
+ public void noCrossDomainWhenIntervalMixesHosts() {
+ // 区间跨到正片切片上,不能整体判为跨域
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, tenSegments(List.of()), 30_000, 80_000,
+ StartOrigin.USER_MARKED, List.of(), false);
+
+ assertFalse(evidence.crossDomain());
+ }
+
+ @Test
+ public void boundedByDiscontinuityRequiresBothEnds() {
+ // 断点在 3(广告块开始)与 6(正片恢复)
+ AdIntervalEvidence both = AdEvidenceCollector.collect(
+ CONTEXT, tenSegments(List.of(3, 6)), 30_000, 60_000,
+ StartOrigin.USER_MARKED, List.of(), false);
+ assertTrue(both.boundedByDiscontinuity());
+
+ // 只有首端有断点
+ AdIntervalEvidence headOnly = AdEvidenceCollector.collect(
+ CONTEXT, tenSegments(List.of(3)), 30_000, 60_000,
+ StartOrigin.USER_MARKED, List.of(), false);
+ assertFalse(headOnly.boundedByDiscontinuity());
+ }
+
+ @Test
+ public void intervalReachingPlaylistEndCountsTailAsBounded() {
+ // 区间到最后一个切片,没有「之后的切片」可带断点
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, tenSegments(List.of(7)), 70_000, 100_000,
+ StartOrigin.USER_MARKED, List.of(), false);
+
+ assertTrue(evidence.boundedByDiscontinuity());
+ }
+
+ @Test
+ public void recordsBlacklistMatches() {
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, tenSegments(List.of()), 30_000, 60_000,
+ StartOrigin.USER_MARKED, List.of("ad-cdn.other.com"), false);
+
+ assertEquals(List.of("ad-cdn.other.com"), evidence.matchedExistingHosts());
+ }
+
+ @Test
+ public void capsOutsideSamplesForLongPlaylists() {
+ List segments = new ArrayList<>();
+ List durations = new ArrayList<>();
+ List switches = new ArrayList<>();
+ for (int i = 0; i < 500; i++) {
+ segments.add("https://v.example.com/seg/" + i + ".ts");
+ durations.add(10f);
+ switches.add(false);
+ }
+ M3u8Evidence long_ = M3u8Evidence.create(segments, List.of(), durations, switches);
+
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, long_, 30_000, 60_000, StartOrigin.USER_MARKED, List.of(), false);
+
+ assertEquals(3, evidence.inside().size());
+ assertEquals(AdEvidenceCollector.MAX_OUTSIDE_SAMPLES, evidence.outside().size());
+ }
+
+ @Test
+ public void handlesMissingPlaylistEvidence() {
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, null, 30_000, 60_000, StartOrigin.FALLBACK_WINDOW, List.of(), true);
+
+ assertTrue(evidence.inside().isEmpty());
+ assertFalse(evidence.hasSegmentEvidence());
+ assertFalse(evidence.crossDomain());
+ assertTrue(evidence.legacyHeuristicActive());
+ // 上下文仍然保留
+ assertEquals("v.example.com", evidence.playlistHost());
+ }
+
+ @Test
+ public void handlesEmptyEvidenceAndNonHlsContext() {
+ AdEvidenceCollector.Context nonHls = new AdEvidenceCollector.Context(
+ "site", "站点", "剧名", "线路", "第 1 集",
+ "https://v.example.com/play/video.mp4", false);
+
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ nonHls, M3u8Evidence.empty(), 30_000, 60_000,
+ StartOrigin.FALLBACK_WINDOW, List.of(), false);
+
+ assertFalse(evidence.hls());
+ assertFalse(evidence.hasSegmentEvidence());
+ }
+
+ @Test
+ public void relativeSegmentUriHasNoHostButKeepsPath() {
+ M3u8Evidence relative = M3u8Evidence.create(
+ List.of("seg/0.ts", "seg/1.ts", "seg/2.ts"),
+ List.of(), List.of(10f, 10f, 10f), List.of(false, false, false));
+
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, relative, 0, 10_000, StartOrigin.USER_MARKED, List.of(), false);
+
+ assertEquals("", evidence.inside().get(0).host());
+ assertEquals("seg/0.ts", evidence.inside().get(0).path());
+ // 无 host 时不能判为跨域
+ assertFalse(evidence.crossDomain());
+ }
+
+ @Test
+ public void toleratesDurationsShorterThanSegments() {
+ // durations 缺项时按较短的长度处理,不抛异常
+ M3u8Evidence mismatched = M3u8Evidence.create(
+ List.of("a.ts", "b.ts", "c.ts"),
+ List.of(), List.of(10f, 10f), List.of(false, false));
+
+ AdIntervalEvidence evidence = AdEvidenceCollector.collect(
+ CONTEXT, mismatched, 0, 10_000, StartOrigin.USER_MARKED, List.of(), false);
+
+ assertEquals(1, evidence.inside().size());
+ assertEquals(1, evidence.outside().size());
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java
new file mode 100644
index 0000000000..86785af554
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java
@@ -0,0 +1,321 @@
+package com.fongmi.android.tv.ad.feedback;
+
+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 com.fongmi.android.tv.bean.M3u8Evidence;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class AdFeedbackControllerTest {
+
+ /** 10 个 10s 切片,下标 3-5 来自广告域名,断点在 3 与 6。 */
+ private static M3u8Evidence adBlockPlaylist() {
+ List segments = new ArrayList<>();
+ List durations = new ArrayList<>();
+ List switches = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ boolean ad = i >= 3 && i <= 5;
+ segments.add(ad ? "https://ad-cdn.other.com/ads/" + i + ".ts"
+ : "https://v.example.com/seg/" + i + ".ts");
+ durations.add(ad ? 6.4f : 10f);
+ switches.add(ad);
+ }
+ return M3u8Evidence.create(segments, List.of(3, 6), durations, switches);
+ }
+
+ @Test
+ public void showsPendingSessionBeforeAnalysisThenVerdict() {
+ FakeHost host = new FakeHost();
+ host.playlist = adBlockPlaylist();
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ AdFeedbackSession session = controller.onMarkedInterval(30_000, 60_000);
+
+ assertNotNull(session);
+ // 第一屏先出,第二屏带归因
+ assertEquals(2, host.shownSessions.size());
+ assertFalse(host.shownSessions.get(0).analysisComplete());
+ assertTrue(host.shownSessions.get(1).analysisComplete());
+ assertTrue(host.shownSessions.get(1).hasActionablePlan());
+ }
+
+ @Test
+ public void skipsImmediatelyAndRecordsOutcome() {
+ FakeHost host = new FakeHost();
+ host.playlist = adBlockPlaylist();
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ AdFeedbackSession session = controller.onMarkedInterval(30_000, 60_000);
+
+ assertTrue(session.skipApplied());
+ assertEquals(List.of("30000-60000"), host.skipCalls);
+ assertEquals(1, controller.diagnostics()
+ .count(AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_APPLIED));
+ }
+
+ @Test
+ public void recordsRejectedSkipWithoutFailingTheFeedback() {
+ FakeHost host = new FakeHost();
+ host.playlist = adBlockPlaylist();
+ host.skipResult = false;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ AdFeedbackSession session = controller.onMarkedInterval(30_000, 60_000);
+
+ // 未跳过不影响归因继续
+ assertFalse(session.skipApplied());
+ assertTrue(host.shownSessions.get(1).hasActionablePlan());
+ assertEquals(1, controller.diagnostics()
+ .count(AdFeedbackDiagnostics.Code.IMMEDIATE_SKIP_REJECTED));
+ }
+
+ @Test
+ public void quickReportInfersStartFromNearestDiscontinuity() {
+ FakeHost host = new FakeHost();
+ host.playlist = adBlockPlaylist();
+ host.position = 60_000;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ AdFeedbackSession session = controller.onQuickReport(host.playlist);
+
+ assertNotNull(session);
+ // 断点在下标 3 与 6。广告切片是 6.4s,故下标 6 起点为
+ // 3×10 + 3×6.4 = 49.2s,是 60s 之前最近的那个断点。
+ assertEquals(49_200L, session.startMs());
+ assertEquals(StartOrigin.DISCONTINUITY, session.startOrigin());
+ assertEquals(1, controller.diagnostics()
+ .count(AdFeedbackDiagnostics.Code.START_INFERRED_FROM_DISCONTINUITY));
+ }
+
+ @Test
+ public void quickReportPicksEarlierDiscontinuityWhenCloserOneIsAhead() {
+ FakeHost host = new FakeHost();
+ host.playlist = adBlockPlaylist();
+ // 位置落在广告块中间:只有下标 3 的断点在它之前
+ host.position = 40_000;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ AdFeedbackSession session = controller.onQuickReport(host.playlist);
+
+ assertEquals(30_000L, session.startMs());
+ assertEquals(StartOrigin.DISCONTINUITY, session.startOrigin());
+ }
+
+ @Test
+ public void quickReportFallsBackToWindowWithoutPlaylist() {
+ FakeHost host = new FakeHost();
+ host.position = 200_000;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ AdFeedbackSession session = controller.onQuickReport(null);
+
+ assertEquals(200_000L - AdIntervalMapper.DEFAULT_FALLBACK_WINDOW_MS, session.startMs());
+ assertEquals(StartOrigin.FALLBACK_WINDOW, session.startOrigin());
+ assertEquals(1, controller.diagnostics()
+ .count(AdFeedbackDiagnostics.Code.START_INFERRED_FROM_FALLBACK));
+ }
+
+ @Test
+ public void rejectsInvalidIntervals() {
+ FakeHost host = new FakeHost();
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ assertNull(controller.onMarkedInterval(40_000, 40_000));
+ assertNull(controller.onMarkedInterval(50_000, 40_000));
+ assertNull(controller.onMarkedInterval(-1_000, 40_000));
+ // 起点超出总时长
+ assertNull(controller.onMarkedInterval(700_000, 800_000));
+ assertTrue(host.skipCalls.isEmpty());
+ assertTrue(host.shownSessions.isEmpty());
+ }
+
+ @Test
+ public void quickReportAtZeroPositionIsRejected() {
+ FakeHost host = new FakeHost();
+ host.position = 0;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ assertNull(controller.onQuickReport(null));
+ }
+
+ @Test
+ public void surfacesAlreadyHandledDiagnosisWhenHostIsBlacklisted() {
+ FakeHost host = new FakeHost();
+ host.playlist = adBlockPlaylist();
+ host.blacklist = List.of("ad-cdn.other.com");
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ controller.onMarkedInterval(30_000, 60_000);
+
+ AdFeedbackSession result = host.shownSessions.get(1);
+ assertTrue(result.verdict().diagnostics().stream()
+ .anyMatch(a -> a.category() == AdCategory.ALREADY_HANDLED));
+ assertEquals(1, controller.diagnostics()
+ .count(AdFeedbackDiagnostics.Code.ALREADY_HANDLED_DETECTED));
+ }
+
+ @Test
+ public void prefersEnablingDisabledRuleOverNewRule() {
+ FakeHost host = new FakeHost();
+ host.playlist = adBlockPlaylist();
+ host.ruleStates = List.of(new ExistingRuleClassifier.RuleState(
+ "builtin:x", "x-rule", "实验规则", false, true, List.of("ad-cdn.other.com")));
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ controller.onMarkedInterval(30_000, 60_000);
+
+ assertEquals(RemediationKind.ENABLE_EXISTING_RULE,
+ host.shownSessions.get(1).verdict().preferred().remediation());
+ }
+
+ @Test
+ public void fallsBackToSessionSkipWhenNoChannelHasEvidence() {
+ FakeHost host = new FakeHost();
+ // 无 playlist、无基线、无规则:全部通道弃权
+ host.legacyActive = true;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ controller.onMarkedInterval(30_000, 60_000);
+
+ AdFeedbackSession result = host.shownSessions.get(1);
+ assertTrue(result.analysisComplete());
+ assertFalse(result.hasActionablePlan());
+ assertEquals(3, controller.diagnostics()
+ .count(AdFeedbackDiagnostics.Code.CHANNEL_ABSTAINED));
+ }
+
+ @Test
+ public void evidenceFailureStillCompletesWithEmptyVerdict() {
+ FakeHost host = new FakeHost();
+ host.throwOnFetch = true;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ controller.onMarkedInterval(30_000, 60_000);
+
+ AdFeedbackSession result = host.shownSessions.get(1);
+ assertTrue(result.analysisComplete());
+ assertTrue(result.verdict().empty());
+ assertEquals(1, controller.diagnostics()
+ .count(AdFeedbackDiagnostics.Code.EVIDENCE_COLLECT_FAILED));
+ }
+
+ @Test
+ public void staleAnalysisDoesNotOverwriteNewerFeedback() {
+ FakeHost host = new FakeHost();
+ host.playlist = adBlockPlaylist();
+ host.deferBackground = true;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ controller.onMarkedInterval(30_000, 60_000);
+ controller.onMarkedInterval(70_000, 90_000);
+ // 按倒序执行:先跑第二次的分析,再跑第一次的过期分析
+ List pending = new ArrayList<>(host.backgroundTasks);
+ host.deferBackground = false;
+ pending.get(1).run();
+ pending.get(0).run();
+
+ // 最后一次展示必须属于第二个区间
+ AdFeedbackSession last = host.shownSessions.get(host.shownSessions.size() - 1);
+ assertEquals(70_000L, last.startMs());
+ }
+
+ private static final class FakeHost implements AdFeedbackController.Host {
+ private final List skipCalls = new ArrayList<>();
+ private final List shownSessions = new ArrayList<>();
+ private final List backgroundTasks = new ArrayList<>();
+ private M3u8Evidence playlist;
+ private long position = 45_000;
+ private boolean skipResult = true;
+ private boolean legacyActive;
+ private boolean throwOnFetch;
+ private boolean deferBackground;
+ private List blacklist = List.of();
+ private List ruleStates = List.of();
+
+ @Override
+ public long positionMs() {
+ return position;
+ }
+
+ @Override
+ public long durationMs() {
+ return 600_000L;
+ }
+
+ @Override
+ public AdEvidenceCollector.Context context() {
+ return new AdEvidenceCollector.Context("site", "站点", "剧名", "线路", "第 1 集",
+ "https://v.example.com/play/index.m3u8", true);
+ }
+
+ @Override
+ public M3u8Evidence fetchEvidence() {
+ if (throwOnFetch) throw new IllegalStateException("fetch failed");
+ return playlist;
+ }
+
+ @Override
+ public boolean skipInterval(long startMs, long endMs, String feedbackId) {
+ skipCalls.add(startMs + "-" + endMs);
+ return skipResult;
+ }
+
+ @Override
+ public List blacklistedHosts() {
+ return blacklist;
+ }
+
+ @Override
+ public List siteBaselineHosts() {
+ return List.of("v.example.com");
+ }
+
+ @Override
+ public List interfaceCandidateHosts() {
+ return List.of();
+ }
+
+ @Override
+ public String interfaceSourceName() {
+ return "";
+ }
+
+ @Override
+ public List hlsRuleStates() {
+ return ruleStates;
+ }
+
+ @Override
+ public List protectingExcludes() {
+ return List.of();
+ }
+
+ @Override
+ public boolean legacyHeuristicActive() {
+ return legacyActive;
+ }
+
+ @Override
+ public void runBackground(Runnable task) {
+ backgroundTasks.add(task);
+ if (!deferBackground) task.run();
+ }
+
+ @Override
+ public void runOnUi(Runnable task) {
+ task.run();
+ }
+
+ @Override
+ public void showSession(AdFeedbackSession session) {
+ shownSessions.add(session);
+ }
+ }
+}
From d95c5537fced9da79cb055f62c322b0181e3e1fb Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sat, 29 Aug 2026 23:59:05 +0800
Subject: [PATCH 08/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=BD=92?=
=?UTF-8?q?=E5=9B=A0=E6=95=B0=E6=8D=AE=E6=BA=90=E4=B8=8E=E7=AB=99=E7=82=B9?=
=?UTF-8?q?=E5=9F=9F=E5=90=8D=E5=9F=BA=E7=BA=BF=E7=9A=84=E5=BA=94=E7=94=A8?=
=?UTF-8?q?=E7=BA=A7=E8=90=BD=E5=9C=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第八个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 5.1、8.3 节。
- AdFeedbackDataSource:把 RuleConfig / UserAdRuleStore /
ImportedAdRuleCandidateStore / HlsRuleConfig 的静态单例数据读成分类器入参。
三个分类器刻意做成纯函数以便单测,读 SharedPreferences 集中在这里,
每个读取点都吞 RuntimeException —— 归因是辅助功能,不能因为读配置失败
影响播放。
- SiteHostBaselineStore:SitePlaylistHostBaseline 的 Prefers 实现与单例。
hostSuffixesOf 从 HlsRuleConfig.Entry.detail() 的 JSON 里取切片域名条件:
HlsAdRule 的字段全部私有且无 getter,而 Entry.detail() 已经是它的完整
JSON 序列化,不必为此给上游 bean 加访问器。解析失败一律返回空列表。
尚未接线到播放器,无行为变化。
Verification: testMobileArm64_v8aDebugUnitTest ad.feedback: 89 tests, 0 failures (AdFeedbackDataSourceTest 5 new); compileMobileArm64_v8aDebug + compileLeanbackArm64_v8aDebug OK
Task-Guard: AF4d-android-bridge
---
.../tv/ad/feedback/AdFeedbackDataSource.java | 125 ++++++++++++++++++
.../tv/ad/feedback/SiteHostBaselineStore.java | 45 +++++++
.../ad/feedback/AdFeedbackDataSourceTest.java | 77 +++++++++++
3 files changed, 247 insertions(+)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSourceTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java
new file mode 100644
index 0000000000..15d864cb42
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSource.java
@@ -0,0 +1,125 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import com.fongmi.android.tv.api.config.HlsRuleConfig;
+import com.fongmi.android.tv.api.config.ImportedAdRuleCandidateStore;
+import com.fongmi.android.tv.api.config.RuleConfig;
+import com.fongmi.android.tv.api.config.UserAdRuleStore;
+import com.fongmi.android.tv.bean.ImportedAdRuleCandidate;
+import com.fongmi.android.tv.bean.UserAdRule;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 把项目里的静态单例数据读成分类器需要的入参。
+ *
+ * 三个分类器刻意做成纯函数以便单测,因此「读 SharedPreferences」这件事
+ * 集中在这里。见设计文档第 5.1 节。
+ */
+public final class AdFeedbackDataSource {
+
+ private AdFeedbackDataSource() {
+ }
+
+ /** 现有广告域名黑名单:VOD ads + Live ads + 用户规则 hosts。 */
+ public static List blacklistedHosts() {
+ try {
+ return List.copyOf(RuleConfig.get().getAds());
+ } catch (RuntimeException e) {
+ return List.of();
+ }
+ }
+
+ /** 待审接口候选里被判为广告域名的条目。 */
+ public static List interfaceCandidateHosts() {
+ try {
+ Set hosts = new LinkedHashSet<>();
+ for (ImportedAdRuleCandidate candidate : ImportedAdRuleCandidateStore.pending()) {
+ if (candidate == null) continue;
+ hosts.addAll(candidate.getHosts());
+ }
+ return List.copyOf(hosts);
+ } catch (RuntimeException e) {
+ return List.of();
+ }
+ }
+
+ /** 第一条待审候选的来源接口名,用于证据展示。 */
+ public static String interfaceSourceName() {
+ try {
+ for (ImportedAdRuleCandidate candidate : ImportedAdRuleCandidateStore.pending()) {
+ if (candidate == null) continue;
+ String name = candidate.getSourceConfigName();
+ if (!name.isEmpty()) return name;
+ }
+ } catch (RuntimeException ignored) {
+ // 读取失败不影响归因
+ }
+ return "";
+ }
+
+ /**
+ * 用户规则中的正片保护正则。这些规则可能误保护了广告切片,
+ * 是 {@link ExistingRuleClassifier} 的诊断输入之一。
+ */
+ public static List protectingExcludes() {
+ try {
+ List excludes = new ArrayList<>();
+ for (UserAdRule rule : UserAdRuleStore.load()) {
+ if (rule == null || !rule.isEnabled()) continue;
+ excludes.addAll(rule.getExclude());
+ }
+ return List.copyOf(excludes);
+ } catch (RuntimeException e) {
+ return List.of();
+ }
+ }
+
+ /**
+ * 已知 HLS 规则状态。{@code HlsAdRule} 未暴露 hostSuffixes 的 getter,
+ * 因此从 {@link HlsRuleConfig.Entry#detail()} 的 JSON 里取。
+ */
+ public static List hlsRuleStates() {
+ try {
+ List states = new ArrayList<>();
+ for (HlsRuleConfig.Entry entry : HlsRuleConfig.getEntries()) {
+ if (entry == null) continue;
+ states.add(new ExistingRuleClassifier.RuleState(
+ entry.key(), entry.id(), entry.name(),
+ entry.enabled(), entry.valid(),
+ hostSuffixesOf(entry.detail())));
+ }
+ return List.copyOf(states);
+ } catch (RuntimeException e) {
+ return List.of();
+ }
+ }
+
+ /** 从规则详情 JSON 里解析 hostSuffixes。解析失败返回空列表,不抛异常。 */
+ static List hostSuffixesOf(String detailJson) {
+ if (detailJson == null || detailJson.isBlank()) return List.of();
+ try {
+ JsonElement parsed = JsonParser.parseString(detailJson);
+ if (!parsed.isJsonObject()) return List.of();
+ JsonObject object = parsed.getAsJsonObject();
+ JsonElement hosts = object.get("hostSuffixes");
+ if (hosts == null || !hosts.isJsonArray()) return List.of();
+ JsonArray array = hosts.getAsJsonArray();
+ List result = new ArrayList<>(array.size());
+ for (JsonElement element : array) {
+ if (element == null || element.isJsonNull()) continue;
+ String value = element.getAsString();
+ if (value != null && !value.isBlank()) result.add(value.trim());
+ }
+ return List.copyOf(result);
+ } catch (RuntimeException e) {
+ return List.of();
+ }
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java
new file mode 100644
index 0000000000..b3470cdac8
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/SiteHostBaselineStore.java
@@ -0,0 +1,45 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import com.github.catvod.utils.Prefers;
+
+/**
+ * {@link SitePlaylistHostBaseline} 的应用级单例,持久化到 SharedPreferences。
+ */
+public final class SiteHostBaselineStore {
+
+ private static final String PREF_KEY = "ad_feedback_site_hosts";
+
+ private static volatile SitePlaylistHostBaseline instance;
+
+ private SiteHostBaselineStore() {
+ }
+
+ public static SitePlaylistHostBaseline get() {
+ if (instance == null) {
+ synchronized (SiteHostBaselineStore.class) {
+ if (instance == null) instance = new SitePlaylistHostBaseline(new PrefersStorage());
+ }
+ }
+ return instance;
+ }
+
+ private static final class PrefersStorage implements SitePlaylistHostBaseline.Storage {
+ @Override
+ public String read() {
+ try {
+ return Prefers.getString(PREF_KEY, "");
+ } catch (RuntimeException e) {
+ return "";
+ }
+ }
+
+ @Override
+ public void write(String value) {
+ try {
+ Prefers.put(PREF_KEY, value == null ? "" : value);
+ } catch (RuntimeException ignored) {
+ // 基线只是归因的辅助输入,写失败不应影响播放
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSourceTest.java
new file mode 100644
index 0000000000..7e750d33f3
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackDataSourceTest.java
@@ -0,0 +1,77 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.List;
+
+/**
+ * {@link AdFeedbackDataSource} 的纯解析部分。
+ *
+ * 读取静态单例的方法依赖 Android 运行时,无法在此覆盖;这里只锁定
+ * {@code hostSuffixesOf} 的解析行为,它是规则状态能否被正确识别的关键。
+ */
+public class AdFeedbackDataSourceTest {
+
+ @Test
+ public void parsesHostSuffixesFromRuleDetail() {
+ String detail = "{\"id\":\"baofeng\",\"hostSuffixes\":[\"ad-cdn.a.com\",\"ads.b.com\"],"
+ + "\"minimumSignals\":2}";
+
+ assertEquals(List.of("ad-cdn.a.com", "ads.b.com"),
+ AdFeedbackDataSource.hostSuffixesOf(detail));
+ }
+
+ @Test
+ public void trimsAndSkipsBlankEntries() {
+ String detail = "{\"hostSuffixes\":[\" ad.a.com \",\"\",\" \",null,\"ad.b.com\"]}";
+
+ assertEquals(List.of("ad.a.com", "ad.b.com"),
+ AdFeedbackDataSource.hostSuffixesOf(detail));
+ }
+
+ @Test
+ public void returnsEmptyWhenFieldMissingOrWrongType() {
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf("{\"id\":\"x\"}").isEmpty());
+ // 规则只用 playlistHostSuffixes 限定作用域,没有切片域名条件
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf(
+ "{\"playlistHostSuffixes\":[\"v.a.com\"]}").isEmpty());
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf("{\"hostSuffixes\":\"ad.a.com\"}").isEmpty());
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf("{\"hostSuffixes\":null}").isEmpty());
+ }
+
+ @Test
+ public void toleratesMalformedInput() {
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf(null).isEmpty());
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf("").isEmpty());
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf(" ").isEmpty());
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf("not json").isEmpty());
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf("[1,2,3]").isEmpty());
+ assertTrue(AdFeedbackDataSource.hostSuffixesOf("{\"hostSuffixes\":[").isEmpty());
+ }
+
+ @Test
+ public void parsedSuffixesFeedRuleStateMatching() {
+ // 端到端:解析出的后缀能让 ExistingRuleClassifier 命中区间切片
+ List suffixes = AdFeedbackDataSource.hostSuffixesOf(
+ "{\"hostSuffixes\":[\"ad-cdn.other.com\"]}");
+ ExistingRuleClassifier.RuleState disabled = new ExistingRuleClassifier.RuleState(
+ "builtin:x", "x", "实验规则", false, true, suffixes);
+
+ AdIntervalEvidence evidence = new AdIntervalEvidence(
+ 10_000, 40_000, StartOrigin.USER_MARKED,
+ "site", "站点", "剧名", "线路", "第 1 集",
+ "v.example.com", "/play/index.m3u8", true,
+ List.of(new SegmentFact(3, "ad-cdn.other.com", "/seg/3.ts", 6.4, true)),
+ List.of(new SegmentFact(0, "v.example.com", "/seg/0.ts", 8.0, false)),
+ false, false, List.of(), false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+
+ AdAttribution attribution = ExistingRuleClassifier.classify(evidence,
+ new ExistingRuleClassifier.Input(List.of(disabled), false, List.of()));
+
+ assertEquals(RemediationKind.ENABLE_EXISTING_RULE, attribution.remediation());
+ }
+}
From 48731cbbbf094b5178c2e7672a497ebb418763bc Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sun, 30 Aug 2026 00:08:24 +0800
Subject: [PATCH 09/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E4=B8=89?=
=?UTF-8?q?=E7=AB=AF=E5=85=B1=E7=94=A8=E7=9A=84=E5=8F=8D=E9=A6=88=20Host?=
=?UTF-8?q?=20=E9=80=82=E9=85=8D=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第九个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 5.2 节。
AdFeedbackHostAdapter 实现 AdFeedbackController.Host,把 Activity 需要提供的
东西收窄成三个小接口:Playback(播放状态与跳过)、Metadata(剧集元数据)、
Ui(线程调度与展示)。读本地配置、抓 playlist、维护站点域名基线都在适配器内,
三端 Activity 因此不必各写一份。
- 配置读取全部经 Deps 注入,默认指向 AdFeedbackDataSource,测试可替换;
每个读取点吞 RuntimeException 并降级为空集合。
- fetchEvidence 缓存最近一次成功抓取的证据供短按推断起点复用,
invalidateEvidence() 供换源换集时清理,避免用上一集的切片结构。
- legacyHeuristicActive 对点播 HLS 恒为 true:HlsAdblockPipeline 两个调用点
都传 legacyFallback=true 且内置规则默认全关,旧启发式实际总在生效。
尚未接线到播放器,无行为变化。
Verification: testMobileArm64_v8aDebugUnitTest ad.feedback: 99 tests, 0 failures (AdFeedbackHostAdapterTest 10 new); compileMobileArm64_v8aDebug + compileLeanbackArm64_v8aDebug OK
Task-Guard: AF4e-host-adapter
---
.../tv/ad/feedback/AdFeedbackHostAdapter.java | 233 +++++++++++++++++
.../feedback/AdFeedbackHostAdapterTest.java | 237 ++++++++++++++++++
2 files changed, 470 insertions(+)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapterTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java
new file mode 100644
index 0000000000..7fd1723534
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java
@@ -0,0 +1,233 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import com.fongmi.android.tv.bean.M3u8Evidence;
+import com.fongmi.android.tv.utils.M3u8Parser;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+/**
+ * {@link AdFeedbackController.Host} 的通用实现,三端共用。
+ *
+ * Activity 只需提供播放状态、剧集元数据和三个调度/展示回调;读取本地配置、
+ * 抓取 playlist、维护站点域名基线都在这里完成。见设计文档第 5.2 节。
+ */
+public final class AdFeedbackHostAdapter implements AdFeedbackController.Host {
+
+ /** 播放器状态。{@code hls} 由调用方判定,避免本类依赖 media3。 */
+ public interface Playback {
+ long positionMs();
+
+ long durationMs();
+
+ String playUrl();
+
+ Map headers();
+
+ boolean hls();
+
+ /** 执行本次跳过,通常委托给 {@code PlayerManager.skipUserAdInterval}。 */
+ boolean skipInterval(long startMs, long endMs, String feedbackId);
+
+ /** 音频/语音通道已有候选的起点,无则返回负数。 */
+ default long audioCandidateStartMs() {
+ return -1L;
+ }
+ }
+
+ /** 当前剧集元数据。 */
+ public interface Metadata {
+ String siteKey();
+
+ String siteName();
+
+ String vodName();
+
+ String flagName();
+
+ String episodeName();
+ }
+
+ /** 线程调度与界面展示。 */
+ public interface Ui {
+ void runBackground(Runnable task);
+
+ void runOnUi(Runnable task);
+
+ void showSession(AdFeedbackSession session);
+ }
+
+ /**
+ * 本地配置读取入口,默认指向 {@link AdFeedbackDataSource},测试可替换。
+ */
+ public record Deps(Supplier> blacklistedHosts,
+ Supplier> interfaceCandidateHosts,
+ Supplier interfaceSourceName,
+ Supplier> protectingExcludes,
+ Supplier> hlsRuleStates,
+ SitePlaylistHostBaseline baseline) {
+
+ public static Deps defaults() {
+ return new Deps(
+ AdFeedbackDataSource::blacklistedHosts,
+ AdFeedbackDataSource::interfaceCandidateHosts,
+ AdFeedbackDataSource::interfaceSourceName,
+ AdFeedbackDataSource::protectingExcludes,
+ AdFeedbackDataSource::hlsRuleStates,
+ SiteHostBaselineStore.get());
+ }
+ }
+
+ private final Playback playback;
+ private final Metadata metadata;
+ private final Ui ui;
+ private final Deps deps;
+ /** 最近一次抓到的 playlist 证据,短按推断起点时复用,避免重复请求。 */
+ private volatile M3u8Evidence cachedEvidence;
+
+ public AdFeedbackHostAdapter(Playback playback, Metadata metadata, Ui ui) {
+ this(playback, metadata, ui, Deps.defaults());
+ }
+
+ public AdFeedbackHostAdapter(Playback playback, Metadata metadata, Ui ui, Deps deps) {
+ this.playback = Objects.requireNonNull(playback, "playback");
+ this.metadata = Objects.requireNonNull(metadata, "metadata");
+ this.ui = Objects.requireNonNull(ui, "ui");
+ this.deps = deps == null ? Deps.defaults() : deps;
+ }
+
+ /** 供短按路径复用的缓存证据,可能为 null。 */
+ public M3u8Evidence cachedEvidence() {
+ return cachedEvidence;
+ }
+
+ /** 换源或换集时清掉缓存,避免把上一集的切片结构用于本集。 */
+ public void invalidateEvidence() {
+ cachedEvidence = null;
+ }
+
+ /** 记录一次成功播放的域名,喂给站点基线。 */
+ public void recordPlaybackHost() {
+ String host = AdEvidenceCollector.hostOf(playback.playUrl());
+ if (host.isEmpty()) return;
+ deps.baseline().record(metadata.siteKey(), host);
+ }
+
+ @Override
+ public long positionMs() {
+ return playback.positionMs();
+ }
+
+ @Override
+ public long durationMs() {
+ return playback.durationMs();
+ }
+
+ @Override
+ public AdEvidenceCollector.Context context() {
+ return new AdEvidenceCollector.Context(
+ metadata.siteKey(), metadata.siteName(), metadata.vodName(),
+ metadata.flagName(), metadata.episodeName(), playback.playUrl(), playback.hls());
+ }
+
+ @Override
+ public M3u8Evidence fetchEvidence() {
+ if (!playback.hls()) return null;
+ String url = playback.playUrl();
+ if (url == null || url.isBlank()) return null;
+ try {
+ M3u8Evidence evidence = M3u8Parser.parse(url, playback.headers());
+ if (evidence != null && !evidence.isEmpty()) cachedEvidence = evidence;
+ return evidence;
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public boolean skipInterval(long startMs, long endMs, String feedbackId) {
+ return playback.skipInterval(startMs, endMs, feedbackId);
+ }
+
+ @Override
+ public List blacklistedHosts() {
+ return orEmpty(deps.blacklistedHosts());
+ }
+
+ @Override
+ public List siteBaselineHosts() {
+ try {
+ return deps.baseline().hosts(metadata.siteKey());
+ } catch (RuntimeException e) {
+ return List.of();
+ }
+ }
+
+ @Override
+ public List interfaceCandidateHosts() {
+ return orEmpty(deps.interfaceCandidateHosts());
+ }
+
+ @Override
+ public String interfaceSourceName() {
+ try {
+ String name = deps.interfaceSourceName().get();
+ return name == null ? "" : name;
+ } catch (RuntimeException e) {
+ return "";
+ }
+ }
+
+ @Override
+ public List hlsRuleStates() {
+ try {
+ List states = deps.hlsRuleStates().get();
+ return states == null ? List.of() : states;
+ } catch (RuntimeException e) {
+ return List.of();
+ }
+ }
+
+ @Override
+ public List protectingExcludes() {
+ return orEmpty(deps.protectingExcludes());
+ }
+
+ @Override
+ public boolean legacyHeuristicActive() {
+ // HlsAdblockPipeline 在两个调用点都传 legacyFallback=true,
+ // 且内置规则默认全部关闭,所以点播 HLS 上旧启发式实际总是生效。
+ return playback.hls();
+ }
+
+ @Override
+ public long audioCandidateStartMs() {
+ return playback.audioCandidateStartMs();
+ }
+
+ @Override
+ public void runBackground(Runnable task) {
+ ui.runBackground(task);
+ }
+
+ @Override
+ public void runOnUi(Runnable task) {
+ ui.runOnUi(task);
+ }
+
+ @Override
+ public void showSession(AdFeedbackSession session) {
+ ui.showSession(session);
+ }
+
+ private static List orEmpty(Supplier> supplier) {
+ try {
+ List value = supplier.get();
+ return value == null ? List.of() : value;
+ } catch (RuntimeException e) {
+ return List.of();
+ }
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapterTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapterTest.java
new file mode 100644
index 0000000000..2d7e838a03
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapterTest.java
@@ -0,0 +1,237 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * {@link AdFeedbackHostAdapter} 的可测部分:配置读取的容错、站点基线记录、
+ * 以及不依赖网络的字段映射。{@code fetchEvidence} 会发起真实请求,此处不覆盖。
+ */
+public class AdFeedbackHostAdapterTest {
+
+ @Test
+ public void mapsMetadataAndPlaybackIntoContext() {
+ FakePlayback playback = new FakePlayback();
+ AdFeedbackHostAdapter adapter = adapter(playback, deps());
+
+ AdEvidenceCollector.Context context = adapter.context();
+
+ assertEquals("site", context.siteKey());
+ assertEquals("剧名", context.vodName());
+ assertEquals("https://v.example.com/play/index.m3u8", context.playUrl());
+ assertTrue(context.hls());
+ assertEquals(45_000L, adapter.positionMs());
+ assertEquals(600_000L, adapter.durationMs());
+ }
+
+ @Test
+ public void recordsPlaybackHostIntoBaseline() {
+ SitePlaylistHostBaseline baseline =
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage());
+ AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), deps(baseline));
+
+ adapter.recordPlaybackHost();
+
+ assertEquals(List.of("v.example.com"), baseline.hosts("site"));
+ assertEquals(List.of("v.example.com"), adapter.siteBaselineHosts());
+ }
+
+ @Test
+ public void skipsBaselineRecordWhenUrlHasNoHost() {
+ SitePlaylistHostBaseline baseline =
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage());
+ FakePlayback playback = new FakePlayback();
+ playback.url = "relative/path.m3u8";
+ AdFeedbackHostAdapter adapter = adapter(playback, deps(baseline));
+
+ adapter.recordPlaybackHost();
+
+ assertEquals(0, baseline.siteCount());
+ }
+
+ @Test
+ public void configReadFailuresDegradeToEmptyInsteadOfCrashing() {
+ AdFeedbackHostAdapter.Deps throwing = new AdFeedbackHostAdapter.Deps(
+ () -> { throw new IllegalStateException("prefs unavailable"); },
+ () -> { throw new IllegalStateException("prefs unavailable"); },
+ () -> { throw new IllegalStateException("prefs unavailable"); },
+ () -> { throw new IllegalStateException("prefs unavailable"); },
+ () -> { throw new IllegalStateException("prefs unavailable"); },
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()));
+ AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), throwing);
+
+ // 归因是辅助功能,读配置失败必须降级而不是把异常抛给播放中的用户
+ assertTrue(adapter.blacklistedHosts().isEmpty());
+ assertTrue(adapter.interfaceCandidateHosts().isEmpty());
+ assertEquals("", adapter.interfaceSourceName());
+ assertTrue(adapter.protectingExcludes().isEmpty());
+ assertTrue(adapter.hlsRuleStates().isEmpty());
+ }
+
+ @Test
+ public void nullConfigValuesBecomeEmptyLists() {
+ AdFeedbackHostAdapter.Deps nulls = new AdFeedbackHostAdapter.Deps(
+ () -> null, () -> null, () -> null, () -> null, () -> null,
+ new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()));
+ AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), nulls);
+
+ assertTrue(adapter.blacklistedHosts().isEmpty());
+ assertEquals("", adapter.interfaceSourceName());
+ assertTrue(adapter.hlsRuleStates().isEmpty());
+ }
+
+ @Test
+ public void nonHlsPlaybackSkipsEvidenceFetchAndLegacyFlag() {
+ FakePlayback playback = new FakePlayback();
+ playback.hls = false;
+ AdFeedbackHostAdapter adapter = adapter(playback, deps());
+
+ // 不是 HLS 就不发请求
+ assertNull(adapter.fetchEvidence());
+ // 旧启发式只在点播 HLS 上生效
+ assertFalse(adapter.legacyHeuristicActive());
+ }
+
+ @Test
+ public void legacyHeuristicIsActiveForHlsPlayback() {
+ AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), deps());
+
+ assertTrue(adapter.legacyHeuristicActive());
+ }
+
+ @Test
+ public void blankUrlSkipsEvidenceFetch() {
+ FakePlayback playback = new FakePlayback();
+ playback.url = " ";
+ AdFeedbackHostAdapter adapter = adapter(playback, deps());
+
+ assertNull(adapter.fetchEvidence());
+ }
+
+ @Test
+ public void invalidateEvidenceClearsCache() {
+ AdFeedbackHostAdapter adapter = adapter(new FakePlayback(), deps());
+
+ adapter.invalidateEvidence();
+
+ assertNull(adapter.cachedEvidence());
+ }
+
+ @Test
+ public void delegatesSkipAndAudioCandidateToPlayback() {
+ FakePlayback playback = new FakePlayback();
+ playback.audioStart = 12_000L;
+ AdFeedbackHostAdapter adapter = adapter(playback, deps());
+
+ assertTrue(adapter.skipInterval(10_000, 40_000, "fb-1"));
+ assertEquals(List.of("10000-40000/fb-1"), playback.skipCalls);
+ assertEquals(12_000L, adapter.audioCandidateStartMs());
+ }
+
+ private static AdFeedbackHostAdapter adapter(FakePlayback playback,
+ AdFeedbackHostAdapter.Deps deps) {
+ return new AdFeedbackHostAdapter(playback, new FakeMetadata(), new FakeUi(), deps);
+ }
+
+ private static AdFeedbackHostAdapter.Deps deps() {
+ return deps(new SitePlaylistHostBaseline(new SitePlaylistHostBaseline.MemoryStorage()));
+ }
+
+ private static AdFeedbackHostAdapter.Deps deps(SitePlaylistHostBaseline baseline) {
+ return new AdFeedbackHostAdapter.Deps(
+ List::of, List::of, () -> "", List::of, List::of, baseline);
+ }
+
+ private static final class FakePlayback implements AdFeedbackHostAdapter.Playback {
+ private final List skipCalls = new java.util.ArrayList<>();
+ private String url = "https://v.example.com/play/index.m3u8";
+ private boolean hls = true;
+ private long audioStart = -1L;
+
+ @Override
+ public long positionMs() {
+ return 45_000L;
+ }
+
+ @Override
+ public long durationMs() {
+ return 600_000L;
+ }
+
+ @Override
+ public String playUrl() {
+ return url;
+ }
+
+ @Override
+ public Map headers() {
+ return Map.of();
+ }
+
+ @Override
+ public boolean hls() {
+ return hls;
+ }
+
+ @Override
+ public boolean skipInterval(long startMs, long endMs, String feedbackId) {
+ skipCalls.add(startMs + "-" + endMs + "/" + feedbackId);
+ return true;
+ }
+
+ @Override
+ public long audioCandidateStartMs() {
+ return audioStart;
+ }
+ }
+
+ private static final class FakeMetadata implements AdFeedbackHostAdapter.Metadata {
+ @Override
+ public String siteKey() {
+ return "site";
+ }
+
+ @Override
+ public String siteName() {
+ return "站点";
+ }
+
+ @Override
+ public String vodName() {
+ return "剧名";
+ }
+
+ @Override
+ public String flagName() {
+ return "线路";
+ }
+
+ @Override
+ public String episodeName() {
+ return "第 1 集";
+ }
+ }
+
+ private static final class FakeUi implements AdFeedbackHostAdapter.Ui {
+ @Override
+ public void runBackground(Runnable task) {
+ task.run();
+ }
+
+ @Override
+ public void runOnUi(Runnable task) {
+ task.run();
+ }
+
+ @Override
+ public void showSession(AdFeedbackSession session) {
+ }
+ }
+}
From f19d4a0e97c1deb7fa764a6cd7db0ab77af89489 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sun, 30 Aug 2026 00:27:46 +0800
Subject: [PATCH 10/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=8C=BA?=
=?UTF-8?q?=E9=97=B4=E5=8F=8D=E9=A6=88=E4=B8=A4=E5=B1=8F=E5=AF=B9=E8=AF=9D?=
=?UTF-8?q?=E6=A1=86=E4=B8=8E=E4=B8=89=E8=AF=AD=E8=A8=80=E6=96=87=E6=A1=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第十个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 4.3 节。
AdFeedbackDialog 放在 main 源集三端共用,参照 AdSkipPromptPresenter 的模式:
用 MaterialAlertDialogBuilder + 代码搭建视图,不写 layout xml。理由是内容
本身是不定长的证据列表,用 layout 需要为 mobile/leanback 各维护一份文件,
而现有 dialog_ad_rule_preview.xml 已经是那样重复了两份。
- 第一屏提交瞬间即出:区间、时长、起点来源、是否已跳过。
- 第二屏归因完成后原地替换正文,同一 feedbackId 不重建对话框,
避免 TV 上闪一下丢焦点;只有「保存规则」按钮的有无发生变化时才重建,
因为 AlertDialog 不支持事后增删按钮。
- 起点来源逐一展示,让用户知道 02:15 是框选的还是推断的。
- 按钮在 TV 上居中,遥控器更容易命中。
新增 27 条文案,en/zh-rCN/zh-rTW 三份齐全,格式串统一用带位置索引的
%1$ 形式以通过 lint 的 StringFormat 检查。
对话框尚无调用方,无行为变化。
Verification: compileMobileArm64_v8aDebug + compileLeanbackArm64_v8aDebug OK; lintVitalMobileArm64_v8aRelease OK (no StringFormat/MissingTranslation)
Task-Guard: AF4f-feedback-dialog
---
.../tv/ui/dialog/AdFeedbackDialog.java | 246 ++++++++++++++++++
app/src/main/res/values-zh-rCN/strings.xml | 25 ++
app/src/main/res/values-zh-rTW/strings.xml | 25 ++
app/src/main/res/values/strings.xml | 25 ++
4 files changed, 321 insertions(+)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ui/dialog/AdFeedbackDialog.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdFeedbackDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdFeedbackDialog.java
new file mode 100644
index 0000000000..82297d1f97
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdFeedbackDialog.java
@@ -0,0 +1,246 @@
+package com.fongmi.android.tv.ui.dialog;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.FragmentActivity;
+
+import com.fongmi.android.tv.R;
+import com.fongmi.android.tv.ad.feedback.AdAttribution;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackSession;
+import com.fongmi.android.tv.ad.feedback.RemediationKind;
+import com.fongmi.android.tv.ad.feedback.RiskLevel;
+import com.fongmi.android.tv.ad.feedback.StartOrigin;
+import com.fongmi.android.tv.utils.ResUtil;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.lang.ref.WeakReference;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 区间反馈对话框。第一屏在提交瞬间就出,第二屏在归因完成后原地替换内容。
+ *
+ * 视图用代码搭建而非 layout:三端共用一份,且内容是不定长的证据列表,
+ * 用 layout 反而要为每端各维护一份文件。见设计文档第 4.3 节。
+ */
+public final class AdFeedbackDialog implements AutoCloseable {
+
+ /** 用户确认保存规则。 */
+ public interface Callback {
+ void onSave(AdAttribution plan);
+ }
+
+ private final WeakReference activity;
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private final Callback callback;
+ private AlertDialog dialog;
+ private TextView body;
+ private String feedbackId;
+ private boolean closed;
+
+ public AdFeedbackDialog(FragmentActivity activity, Callback callback) {
+ this.activity = new WeakReference<>(activity);
+ this.callback = callback;
+ }
+
+ /** 展示或原地刷新。同一 feedbackId 的后续调用只替换正文。 */
+ public void show(AdFeedbackSession session) {
+ if (session == null) return;
+ runOnMain(() -> showInternal(session));
+ }
+
+ @Override
+ public void close() {
+ runOnMain(() -> {
+ if (closed) return;
+ closed = true;
+ dismissInternal();
+ activity.clear();
+ });
+ }
+
+ private void showInternal(AdFeedbackSession session) {
+ FragmentActivity owner = usableActivity();
+ if (owner == null || closed) return;
+
+ // 同一次反馈的第二屏:只换正文,避免对话框闪一下丢焦点
+ if (dialog != null && dialog.isShowing() && session.feedbackId().equals(feedbackId)) {
+ if (body != null) body.setText(describe(owner, session));
+ applyButtons(session);
+ return;
+ }
+
+ dismissInternal();
+ feedbackId = session.feedbackId();
+
+ ScrollView scroll = new ScrollView(owner);
+ LinearLayout content = new LinearLayout(owner);
+ content.setOrientation(LinearLayout.VERTICAL);
+ int padding = ResUtil.dp2px(20);
+ content.setPadding(padding, padding, padding, padding);
+ body = new TextView(owner);
+ body.setTextSize(14f);
+ body.setLineSpacing(0f, 1.2f);
+ body.setText(describe(owner, session));
+ content.addView(body, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+ scroll.addView(content);
+
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(owner)
+ .setTitle(title(owner, session))
+ .setView(scroll)
+ .setNegativeButton(R.string.ad_interval_dismiss, (d, which) -> d.dismiss());
+ if (session.hasActionablePlan()) {
+ builder.setPositiveButton(R.string.ad_interval_save, (d, which) -> onSave(session));
+ }
+ dialog = builder.create();
+ dialog.setOnDismissListener(d -> {
+ dialog = null;
+ body = null;
+ });
+ dialog.show();
+ centerButtons();
+ }
+
+ /**
+ * 归因完成后可能才出现「保存规则」按钮。AlertDialog 不支持事后加按钮,
+ * 因此重建一次;此时用户尚未与内容交互,重建代价可接受。
+ */
+ private void applyButtons(AdFeedbackSession session) {
+ if (dialog == null) return;
+ boolean hasSave = dialog.getButton(AlertDialog.BUTTON_POSITIVE) != null
+ && dialog.getButton(AlertDialog.BUTTON_POSITIVE).getVisibility() == View.VISIBLE;
+ if (session.hasActionablePlan() == hasSave) return;
+ feedbackId = null;
+ showInternal(session);
+ }
+
+ private void onSave(AdFeedbackSession session) {
+ if (callback == null || session.verdict() == null) return;
+ AdAttribution plan = session.verdict().preferred();
+ if (plan != null) callback.onSave(plan);
+ }
+
+ private String title(FragmentActivity owner, AdFeedbackSession session) {
+ return owner.getString(R.string.ad_interval_title,
+ clock(session.startMs()), clock(session.endMs()));
+ }
+
+ private String describe(FragmentActivity owner, AdFeedbackSession session) {
+ StringBuilder text = new StringBuilder();
+ text.append(owner.getString(R.string.ad_interval_duration, session.durationMs() / 1000f));
+ text.append('\n').append(originText(owner, session));
+ text.append('\n').append(owner.getString(session.skipApplied()
+ ? R.string.ad_interval_skipped : R.string.ad_interval_recorded));
+
+ if (!session.analysisComplete()) {
+ text.append("\n\n").append(owner.getString(R.string.ad_interval_analyzing));
+ return text.toString();
+ }
+
+ AdAttribution preferred = session.verdict().preferred();
+ text.append("\n\n").append(planText(owner, preferred));
+ if (preferred != null) {
+ text.append(" ").append(riskText(owner, preferred.risk()));
+ text.append(String.format(Locale.US, " %d%%",
+ Math.round(preferred.confidence() * 100)));
+ }
+
+ appendLines(text, owner.getString(R.string.ad_interval_evidence),
+ session.preferredEvidence());
+ if (session.verdict() != null && !session.verdict().diagnostics().isEmpty()) {
+ StringBuilder diagnostics = new StringBuilder();
+ for (AdAttribution diagnostic : session.verdict().diagnostics()) {
+ for (String line : diagnostic.evidence()) {
+ if (diagnostics.length() > 0) diagnostics.append('\n');
+ diagnostics.append("· ").append(line);
+ }
+ }
+ if (diagnostics.length() > 0) {
+ text.append("\n\n").append(owner.getString(R.string.ad_interval_diagnostics))
+ .append('\n').append(diagnostics);
+ }
+ }
+ return text.toString();
+ }
+
+ private static void appendLines(StringBuilder text, String header, List lines) {
+ if (lines == null || lines.isEmpty()) return;
+ text.append("\n\n").append(header);
+ for (String line : lines) text.append("\n· ").append(line);
+ }
+
+ private static String originText(FragmentActivity owner, AdFeedbackSession session) {
+ StartOrigin origin = session.startOrigin();
+ if (origin == StartOrigin.USER_MARKED) return owner.getString(R.string.ad_interval_origin_marked);
+ if (origin == StartOrigin.DISCONTINUITY) return owner.getString(R.string.ad_interval_origin_discontinuity);
+ if (origin == StartOrigin.CROSS_DOMAIN) return owner.getString(R.string.ad_interval_origin_cross_domain);
+ if (origin == StartOrigin.AUDIO_CANDIDATE) return owner.getString(R.string.ad_interval_origin_audio);
+ return owner.getString(R.string.ad_interval_origin_window,
+ (int) TimeUnit.MILLISECONDS.toSeconds(session.durationMs()));
+ }
+
+ private static String planText(FragmentActivity owner, AdAttribution plan) {
+ if (plan == null) return owner.getString(R.string.ad_interval_plan_none);
+ RemediationKind kind = plan.remediation();
+ if (kind == RemediationKind.ENABLE_EXISTING_RULE) return owner.getString(R.string.ad_interval_plan_enable);
+ if (kind == RemediationKind.HOST_BLACKLIST) return owner.getString(R.string.ad_interval_plan_host);
+ if (kind == RemediationKind.HLS_STRUCTURED_RULE) return owner.getString(R.string.ad_interval_plan_hls);
+ if (kind == RemediationKind.URL_REGEX_RULE) return owner.getString(R.string.ad_interval_plan_regex);
+ return owner.getString(R.string.ad_interval_plan_none);
+ }
+
+ private static String riskText(FragmentActivity owner, RiskLevel risk) {
+ if (risk == RiskLevel.LOW) return owner.getString(R.string.ad_interval_risk_low);
+ if (risk == RiskLevel.HIGH) return owner.getString(R.string.ad_interval_risk_high);
+ return owner.getString(R.string.ad_interval_risk_medium);
+ }
+
+ /** mm:ss,超过一小时用 h:mm:ss。 */
+ static String clock(long millis) {
+ long total = Math.max(0L, millis) / 1000L;
+ long hours = total / 3600L;
+ long minutes = (total % 3600L) / 60L;
+ long seconds = total % 60L;
+ return hours > 0
+ ? String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds)
+ : String.format(Locale.US, "%02d:%02d", minutes, seconds);
+ }
+
+ /** TV 上按钮默认贴右,遥控器操作时居中更容易命中。 */
+ private void centerButtons() {
+ if (dialog == null || dialog.getWindow() == null) return;
+ View parent = dialog.getWindow().findViewById(android.R.id.button1);
+ if (parent != null && parent.getParent() instanceof LinearLayout buttons) {
+ buttons.setGravity(Gravity.CENTER_HORIZONTAL);
+ }
+ }
+
+ private void dismissInternal() {
+ if (dialog != null) {
+ dialog.setOnDismissListener(null);
+ dialog.dismiss();
+ }
+ dialog = null;
+ body = null;
+ }
+
+ private FragmentActivity usableActivity() {
+ FragmentActivity owner = activity.get();
+ if (owner == null || owner.isFinishing() || owner.isDestroyed()) return null;
+ return owner;
+ }
+
+ private void runOnMain(Runnable task) {
+ if (Looper.myLooper() == Looper.getMainLooper()) task.run();
+ else main.post(task);
+ }
+}
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 8f7993a803..35873279aa 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -2470,6 +2470,31 @@
白名单(正则)
(无)
应用并屏蔽
+ 广告区间 %1$s – %2$s
+ 时长 %1$.1f 秒
+ 本次播放已跳过
+ 已记录。该区间已经播过,未执行跳过。
+ 起点:由你框选
+ 起点:根据切片断点推断
+ 起点:根据切片跨域切换推断
+ 起点:复用音频检测结果
+ 起点:按回溯 %1$d 秒估算
+ 正在分析…
+ 新增 HLS 切片规则
+ 拉黑广告域名
+ 新增 URL 正则规则
+ 启用已有规则
+ 未能归纳出可复用的规则
+ 风险:低
+ 风险:中
+ 风险:高
+ 证据
+ 诊断
+ 保存规则
+ 关闭
+ 已标记广告起点,到结束处再按一次
+ 已取消标记
+ 当前位置无法反馈广告
AI/自定义 %1$d 条 · 接口配置 %2$d 条 · 内置 HLS %3$d 条
内置 HLS · v%1$d · %2$s
已启用
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 5c8cedf5af..fe9fb7816e 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -2423,6 +2423,31 @@
白名單(正則)
(無)
應用並屏蔽
+ 廣告區間 %1$s – %2$s
+ 時長 %1$.1f 秒
+ 本次播放已跳過
+ 已記錄。該區間已經播過,未執行跳過。
+ 起點:由你框選
+ 起點:根據切片斷點推斷
+ 起點:根據切片跨域切換推斷
+ 起點:複用音訊檢測結果
+ 起點:按回溯 %1$d 秒估算
+ 正在分析…
+ 新增 HLS 切片規則
+ 拉黑廣告域名
+ 新增 URL 正則規則
+ 啟用已有規則
+ 未能歸納出可複用的規則
+ 風險:低
+ 風險:中
+ 風險:高
+ 證據
+ 診斷
+ 儲存規則
+ 關閉
+ 已標記廣告起點,到結束處再按一次
+ 已取消標記
+ 當前位置無法反饋廣告
AI/自定義 %1$d 條 · 接口配置 %2$d 條 · 內置 HLS %3$d 條
內置 HLS · v%1$d · %2$s
已啓用
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0181271901..0fc0cb8c5a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2137,6 +2137,31 @@
Allowlist (regex)
(none)
Apply and block
+ Ad interval %1$s – %2$s
+ Length %1$.1fs
+ Skipped for this playback
+ Recorded. The interval already played, so nothing was skipped.
+ start: marked by you
+ start: inferred from segment discontinuity
+ start: inferred from a cross-domain segment switch
+ start: reused from an audio detection
+ start: estimated by rewinding %1$ds
+ Analyzing…
+ Add an HLS segment rule
+ Block the ad domain
+ Add a URL pattern rule
+ Turn on an existing rule
+ No reusable rule could be derived
+ risk: low
+ risk: medium
+ risk: high
+ Evidence
+ Diagnostics
+ Save rule
+ Close
+ Ad start marked. Press again at the end.
+ Marking cancelled
+ Cannot report an ad at this position
AI/custom %1$d · source config %2$d · built-in HLS %3$d
Built-in HLS · v%1$d · %2$s
Enabled
From 9b2c31f1e0372e121f994845fa2294722560f035 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sun, 30 Aug 2026 01:02:35 +0800
Subject: [PATCH 11/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=BD=92?=
=?UTF-8?q?=E5=9B=A0=E7=BB=93=E8=AE=BA=E6=90=BA=E5=B8=A6=E6=9C=BA=E5=99=A8?=
=?UTF-8?q?=E5=8F=AF=E8=AF=BB=E7=9A=84=E8=A7=84=E5=88=99=E8=BD=BD=E8=8D=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第十一个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 6.2、10 节。
接线 UI 时发现一个设计缺口:AdAttribution 只有给人看的 evidence 字符串,
没有落地规则所需的结构化数据。若从证据文本里正则提取域名,规则会随文案
和语言变化而失效 —— 这是不可接受的耦合。
- RulePayload:域名/正则/排除/规则键/时长范围/信号门限。三个分类器各自
填充自己那部分,HLS 通道额外带 playlistHostSuffixes 把作用域收窄到当前
站点,满足设计文档第 10 节「禁止生成全局生效的规则」。
- AdAttribution.actionable() 现在要求载荷非空,否则「保存规则」会写出空规则。
- 仲裁合并同类结论时载荷跟随被选中的机制,不能拿 A 的数据执行 B 的动作。
- 时长范围仅在时长离群信号成立时给出,避免只靠固定时长删片;
minimumSignals 恒不低于 2。
Verification: testMobileArm64_v8aDebugUnitTest ad.feedback: 109 tests, 0 failures (RulePayloadTest 10 new); compileMobileArm64_v8aDebug OK
Task-Guard: AF4g1-payload
---
.../android/tv/ad/feedback/AdAttribution.java | 19 ++-
.../tv/ad/feedback/AdAttributionArbiter.java | 4 +-
.../feedback/DomainReputationClassifier.java | 3 +-
.../ad/feedback/ExistingRuleClassifier.java | 3 +-
.../tv/ad/feedback/HlsSegmentClassifier.java | 31 +++-
.../android/tv/ad/feedback/RulePayload.java | 84 ++++++++++
.../ad/feedback/AdAttributionArbiterTest.java | 17 +-
.../tv/ad/feedback/RulePayloadTest.java | 157 ++++++++++++++++++
8 files changed, 310 insertions(+), 8 deletions(-)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java
index 47f6e145ab..8c02b0adab 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttribution.java
@@ -11,10 +11,12 @@ import java.util.List;
* @param risk 采纳该结论的风险等级
* @param evidence 人类可读的证据,直接进 UI
* @param remediation 建议的去广机制
+ * @param payload 落地规则所需的机器可读数据
*/
public record AdAttribution(
String channelId, AdCategory category, float confidence,
- RiskLevel risk, List evidence, RemediationKind remediation) {
+ RiskLevel risk, List evidence, RemediationKind remediation,
+ RulePayload payload) {
public AdAttribution {
if (channelId == null || channelId.isBlank()) {
@@ -25,6 +27,13 @@ public record AdAttribution(
risk = risk == null ? RiskLevel.MEDIUM : risk;
evidence = evidence == null ? List.of() : List.copyOf(evidence);
remediation = remediation == null ? RemediationKind.NONE : remediation;
+ payload = payload == null ? RulePayload.empty() : payload;
+ }
+
+ /** 纯诊断结论没有可落地的数据。 */
+ public AdAttribution(String channelId, AdCategory category, float confidence,
+ RiskLevel risk, List evidence, RemediationKind remediation) {
+ this(channelId, category, confidence, risk, evidence, remediation, RulePayload.empty());
}
private static float clamp(float value) {
@@ -41,9 +50,13 @@ public record AdAttribution(
return confidence * 0.7f + remediation.priorityWeight() * 0.3f;
}
- /** 是否能产出可落地的规则。 */
+ /**
+ * 是否能产出可落地的规则。除了机制本身要可执行,载荷也必须非空 ——
+ * 否则「保存规则」会写出一条空规则。
+ */
public boolean actionable() {
return remediation != RemediationKind.NONE
- && remediation != RemediationKind.SESSION_SKIP_ONLY;
+ && remediation != RemediationKind.SESSION_SKIP_ONLY
+ && !payload.isEmpty();
}
}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java
index 832ee1ae36..badb414d91 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiter.java
@@ -111,7 +111,9 @@ public final class AdAttributionArbiter {
channels.add(attribution.channelId());
}
merged.add(new AdAttribution(String.join("+", channels), entry.getKey(),
- confidence, risk, List.copyOf(lines), cheapest.remediation()));
+ confidence, risk, List.copyOf(lines), cheapest.remediation(),
+ // 载荷必须跟随被选中的机制,否则会拿 A 的数据去执行 B 的动作
+ cheapest.payload()));
}
return merged;
}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java
index f9fa403c5b..bfacee1a63 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java
@@ -91,7 +91,8 @@ public final class DomainReputationClassifier {
evidenceLines.add("起点来源:" + evidence.startOrigin());
return new AdAttribution(CHANNEL_ID, AdCategory.THIRD_PARTY_CDN_SEGMENT,
- confidence, RiskLevel.LOW, evidenceLines, RemediationKind.HOST_BLACKLIST);
+ confidence, RiskLevel.LOW, evidenceLines, RemediationKind.HOST_BLACKLIST,
+ RulePayload.ofHosts(List.copyOf(foreignHosts)));
}
/** 区间内不属于 playlist 域名的切片 host,去重保序。 */
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java
index 2bf296d0d3..863dc6514e 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/ExistingRuleClassifier.java
@@ -74,7 +74,8 @@ public final class ExistingRuleClassifier {
lines.add("启用它即可拦掉这段广告,无需新建规则");
return new AdAttribution(CHANNEL_ID, AdCategory.DISCONTINUITY_BLOCK,
CONFIDENCE_DISABLED_RULE, RiskLevel.LOW, lines,
- RemediationKind.ENABLE_EXISTING_RULE);
+ RemediationKind.ENABLE_EXISTING_RULE,
+ RulePayload.ofRuleKey(disabled.key()));
}
List lines = new ArrayList<>();
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java
index d17493f823..62c2eda652 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java
@@ -80,7 +80,36 @@ public final class HlsSegmentClassifier {
if (signals.encodableCount() == 0) return null;
return new AdAttribution(CHANNEL_ID, categoryOf(signals), confidence,
- RiskLevel.MEDIUM, describe(evidence, signals), RemediationKind.HLS_STRUCTURED_RULE);
+ RiskLevel.MEDIUM, describe(evidence, signals), RemediationKind.HLS_STRUCTURED_RULE,
+ payloadOf(evidence, signals));
+ }
+
+ /**
+ * 生成可被 {@code HlsManifestCleaner.Rule} 执行的条件。
+ *
+ * 时长范围围绕区间内均值取 ±0.1s,仅在时长离群信号成立时给出 ——
+ * 否则会写出一条只靠固定时长删片的宽泛规则,这是内置规则策略明确禁止的。
+ * minimumSignals 取可编码信号数且不低于 2。
+ */
+ private static RulePayload payloadOf(AdIntervalEvidence evidence, Signals signals) {
+ List hosts = evidence.inside().stream()
+ .map(SegmentFact::host)
+ .filter(host -> !host.isEmpty())
+ .distinct()
+ .toList();
+ double min = Double.NaN;
+ double max = Double.NaN;
+ if (signals.durationOutlier()) {
+ double mean = mean(evidence.inside());
+ min = Math.max(0d, mean - 0.1d);
+ max = mean + 0.1d;
+ }
+ // 作用域必须收窄到当前站点的 playlist 域名,否则规则会污染其他站点
+ List scope = evidence.playlistHost().isEmpty()
+ ? List.of() : List.of(evidence.playlistHost());
+ return RulePayload.ofHlsRule(scope, hosts, min, max,
+ signals.discontinuity(), signals.crossDomain(),
+ Math.max(2, signals.encodableCount()));
}
/** 逐项检测五种信号。 */
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java
new file mode 100644
index 0000000000..12d8131fa6
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java
@@ -0,0 +1,84 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import java.util.List;
+
+/**
+ * 归因结论的机器可读载荷:落地规则所需的实际数据。
+ *
+ * 与 {@link AdAttribution#evidence()} 分开是刻意的 —— 证据是给人看的、
+ * 会随文案和语言变化,不能用来提取域名或规则键。
+ *
+ * @param playlistHostSuffixes 规则作用域,限定到当前站点的 playlist 域名;
+ * HLS 规则必须携带,否则会污染其他站点(设计文档第 10 节)
+ * @param hosts 要拉黑或匹配的切片域名
+ * @param regex 广告 URL 正则
+ * @param exclude 正片保护正则
+ * @param ruleKey 要启用的已有规则的复合状态键
+ * @param durationMin 切片时长下界,NaN 表示不限定
+ * @param durationMax 切片时长上界,NaN 表示不限定
+ * @param requireDiscontinuity 是否要求断点边界
+ * @param requireCrossDomain 是否要求跨域
+ * @param minimumSignals 生成 HLS 规则时的多信号门限
+ */
+public record RulePayload(List playlistHostSuffixes, List hosts,
+ List regex, List exclude,
+ String ruleKey, double durationMin, double durationMax,
+ boolean requireDiscontinuity, boolean requireCrossDomain,
+ int minimumSignals) {
+
+ public RulePayload {
+ playlistHostSuffixes = playlistHostSuffixes == null ? List.of() : List.copyOf(playlistHostSuffixes);
+ hosts = hosts == null ? List.of() : List.copyOf(hosts);
+ regex = regex == null ? List.of() : List.copyOf(regex);
+ exclude = exclude == null ? List.of() : List.copyOf(exclude);
+ ruleKey = ruleKey == null ? "" : ruleKey;
+ if (minimumSignals < 0) minimumSignals = 0;
+ }
+
+ public static RulePayload empty() {
+ return new RulePayload(List.of(), List.of(), List.of(), List.of(), "",
+ Double.NaN, Double.NaN, false, false, 0);
+ }
+
+ /** 仅域名黑名单。 */
+ public static RulePayload ofHosts(List hosts) {
+ return new RulePayload(List.of(), hosts, List.of(), List.of(), "",
+ Double.NaN, Double.NaN, false, false, 0);
+ }
+
+ /** 仅 URL 正则。 */
+ public static RulePayload ofRegex(List regex) {
+ return new RulePayload(List.of(), List.of(), regex, List.of(), "",
+ Double.NaN, Double.NaN, false, false, 0);
+ }
+
+ /** 启用一条已有规则。 */
+ public static RulePayload ofRuleKey(String ruleKey) {
+ return new RulePayload(List.of(), List.of(), List.of(), List.of(), ruleKey,
+ Double.NaN, Double.NaN, false, false, 0);
+ }
+
+ /** HLS 结构化条件,不带时长范围。 */
+ public static RulePayload ofHls(List playlistHostSuffixes, List hosts,
+ int minimumSignals) {
+ return new RulePayload(playlistHostSuffixes, hosts, List.of(), List.of(), "",
+ Double.NaN, Double.NaN, false, false, minimumSignals);
+ }
+
+ /** HLS 结构化条件全量。 */
+ public static RulePayload ofHlsRule(List playlistHostSuffixes, List hosts,
+ double durationMin, double durationMax,
+ boolean requireDiscontinuity, boolean requireCrossDomain,
+ int minimumSignals) {
+ return new RulePayload(playlistHostSuffixes, hosts, List.of(), List.of(), "",
+ durationMin, durationMax, requireDiscontinuity, requireCrossDomain, minimumSignals);
+ }
+
+ public boolean hasDurationRange() {
+ return !Double.isNaN(durationMin) && !Double.isNaN(durationMax);
+ }
+
+ public boolean isEmpty() {
+ return hosts.isEmpty() && regex.isEmpty() && exclude.isEmpty() && ruleKey.isEmpty();
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java
index b09c7c8de6..cfcac83c58 100644
--- a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdAttributionArbiterTest.java
@@ -15,7 +15,22 @@ public class AdAttributionArbiterTest {
private static AdAttribution of(String channel, AdCategory category, float confidence,
RemediationKind kind) {
return new AdAttribution(channel, category, confidence, RiskLevel.MEDIUM,
- List.of(channel + " 证据"), kind);
+ List.of(channel + " 证据"), kind, payloadFor(kind));
+ }
+
+ /**
+ * 可落地的机制必须带非空载荷,否则 {@link AdAttribution#actionable()} 为 false
+ * 并被仲裁器降级成诊断。诊断类机制本身就没有载荷。
+ */
+ private static RulePayload payloadFor(RemediationKind kind) {
+ return switch (kind) {
+ case ENABLE_EXISTING_RULE -> RulePayload.ofRuleKey("builtin|pkg|rule");
+ case HOST_BLACKLIST -> RulePayload.ofHosts(List.of("ad.example.com"));
+ case HLS_STRUCTURED_RULE -> RulePayload.ofHls(
+ List.of("v.example.com"), List.of("ad.example.com"), 2);
+ case URL_REGEX_RULE -> RulePayload.ofRegex(List.of(".*/ad/.*"));
+ default -> RulePayload.empty();
+ };
}
@Test
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java
new file mode 100644
index 0000000000..adfe99a69b
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java
@@ -0,0 +1,157 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.List;
+
+public class RulePayloadTest {
+
+ @Test
+ public void emptyPayloadIsNotActionable() {
+ AdAttribution plan = new AdAttribution("hls", AdCategory.DISCONTINUITY_BLOCK, 0.9f,
+ RiskLevel.MEDIUM, List.of("证据"), RemediationKind.HLS_STRUCTURED_RULE,
+ RulePayload.empty());
+
+ // 机制可执行但没有数据可写,必须视为不可落地
+ assertFalse(plan.actionable());
+ }
+
+ @Test
+ public void diagnosticConstructorDefaultsToEmptyPayload() {
+ AdAttribution plan = new AdAttribution("domain", AdCategory.ALREADY_HANDLED, 0.95f,
+ RiskLevel.LOW, List.of("已在黑名单"), RemediationKind.NONE);
+
+ assertTrue(plan.payload().isEmpty());
+ assertFalse(plan.actionable());
+ }
+
+ @Test
+ public void hostPayloadMakesPlanActionable() {
+ AdAttribution plan = new AdAttribution("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.6f,
+ RiskLevel.LOW, List.of("证据"), RemediationKind.HOST_BLACKLIST,
+ RulePayload.ofHosts(List.of("ad.example.com")));
+
+ assertTrue(plan.actionable());
+ assertEquals(List.of("ad.example.com"), plan.payload().hosts());
+ }
+
+ @Test
+ public void ruleKeyPayloadIsNotEmpty() {
+ RulePayload payload = RulePayload.ofRuleKey("builtin|pkg|baofeng");
+
+ assertFalse(payload.isEmpty());
+ assertEquals("builtin|pkg|baofeng", payload.ruleKey());
+ }
+
+ @Test
+ public void durationRangePresenceIsExplicit() {
+ assertFalse(RulePayload.ofHosts(List.of("a.com")).hasDurationRange());
+ assertTrue(RulePayload.ofHlsRule(List.of("v.a.com"), List.of("ad.a.com"),
+ 6.3, 6.5, true, true, 2).hasDurationRange());
+ }
+
+ @Test
+ public void nullFieldsBecomeEmptyCollections() {
+ RulePayload payload = new RulePayload(null, null, null, null, null,
+ Double.NaN, Double.NaN, false, false, -5);
+
+ assertTrue(payload.playlistHostSuffixes().isEmpty());
+ assertTrue(payload.hosts().isEmpty());
+ assertTrue(payload.regex().isEmpty());
+ assertTrue(payload.exclude().isEmpty());
+ assertEquals("", payload.ruleKey());
+ assertEquals(0, payload.minimumSignals());
+ assertTrue(payload.isEmpty());
+ }
+
+ @Test
+ public void hlsClassifierScopesRuleToPlaylistHostAndKeepsMinimumTwoSignals() {
+ List inside = List.of(
+ new SegmentFact(10, "ad-cdn.other.com", "/seg/10.ts", 6.4, true),
+ new SegmentFact(11, "ad-cdn.other.com", "/seg/11.ts", 6.4, false));
+ List outside = List.of(
+ new SegmentFact(0, "v.example.com", "/seg/0.ts", 8.0, false),
+ new SegmentFact(12, "v.example.com", "/seg/12.ts", 8.0, false));
+ AdIntervalEvidence evidence = new AdIntervalEvidence(
+ 80_000, 92_800, StartOrigin.USER_MARKED,
+ "site", "站点", "剧名", "线路", "第 1 集",
+ "v.example.com", "/play/index.m3u8", true,
+ inside, outside, true, true, List.of(), false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+
+ AdAttribution plan = HlsSegmentClassifier.classify(evidence);
+ RulePayload payload = plan.payload();
+
+ // 作用域收窄到本站,避免规则污染其他站点
+ assertEquals(List.of("v.example.com"), payload.playlistHostSuffixes());
+ assertEquals(List.of("ad-cdn.other.com"), payload.hosts());
+ assertTrue(payload.requireDiscontinuity());
+ assertTrue(payload.requireCrossDomain());
+ // 永不生成 minimumSignals=1 的宽泛规则
+ assertTrue(payload.minimumSignals() >= 2);
+ assertTrue(plan.actionable());
+ }
+
+ @Test
+ public void durationRangeOnlyAppearsWhenOutlierSignalHolds() {
+ // 区间内外时长一致:不给时长条件,避免只靠固定时长删片
+ List inside = List.of(
+ new SegmentFact(10, "ad-cdn.other.com", "/ads/10.ts", 8.0, true),
+ new SegmentFact(11, "ad-cdn.other.com", "/ads/11.ts", 8.0, false));
+ List outside = List.of(
+ new SegmentFact(0, "v.example.com", "/seg/0.ts", 8.0, false),
+ new SegmentFact(12, "v.example.com", "/seg/12.ts", 8.0, false));
+ AdIntervalEvidence evidence = new AdIntervalEvidence(
+ 80_000, 96_000, StartOrigin.USER_MARKED,
+ "site", "站点", "剧名", "线路", "第 1 集",
+ "v.example.com", "/play/index.m3u8", true,
+ inside, outside, true, true, List.of(), false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+
+ RulePayload payload = HlsSegmentClassifier.classify(evidence).payload();
+
+ assertFalse(payload.hasDurationRange());
+ }
+
+ @Test
+ public void existingRuleAttributionCarriesRuleKey() {
+ ExistingRuleClassifier.RuleState disabled = new ExistingRuleClassifier.RuleState(
+ "builtin|pkg|baofeng", "baofeng", "暴风片头", false, true,
+ List.of("ad-cdn.other.com"));
+ AdIntervalEvidence evidence = new AdIntervalEvidence(
+ 10_000, 40_000, StartOrigin.USER_MARKED,
+ "site", "站点", "剧名", "线路", "第 1 集",
+ "v.example.com", "/play/index.m3u8", true,
+ List.of(new SegmentFact(3, "ad-cdn.other.com", "/seg/3.ts", 6.4, true)),
+ List.of(new SegmentFact(0, "v.example.com", "/seg/0.ts", 8.0, false)),
+ false, false, List.of(), false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+
+ AdAttribution plan = ExistingRuleClassifier.classify(evidence,
+ new ExistingRuleClassifier.Input(List.of(disabled), false, List.of()));
+
+ assertEquals("builtin|pkg|baofeng", plan.payload().ruleKey());
+ assertTrue(plan.actionable());
+ }
+
+ @Test
+ public void mergedAttributionKeepsPayloadOfChosenMechanism() {
+ AdAttribution hls = new AdAttribution("hls", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.6f,
+ RiskLevel.MEDIUM, List.of("hls 证据"), RemediationKind.HLS_STRUCTURED_RULE,
+ RulePayload.ofHls(List.of("v.example.com"), List.of("hls-host.com"), 2));
+ AdAttribution domain = new AdAttribution("domain", AdCategory.THIRD_PARTY_CDN_SEGMENT, 0.5f,
+ RiskLevel.LOW, List.of("domain 证据"), RemediationKind.HOST_BLACKLIST,
+ RulePayload.ofHosts(List.of("domain-host.com")));
+
+ AdAttributionArbiter.Verdict verdict = AdAttributionArbiter.arbitrate(List.of(hls, domain));
+ AdAttribution preferred = verdict.preferred();
+
+ // 合并后取成本更低的 HOST_BLACKLIST,载荷必须同步取它的,不能错配
+ assertEquals(RemediationKind.HOST_BLACKLIST, preferred.remediation());
+ assertEquals(List.of("domain-host.com"), preferred.payload().hosts());
+ }
+}
From f5d80b1de097eeaf6ef5c5a711fb30411821bf28 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sun, 30 Aug 2026 01:34:28 +0800
Subject: [PATCH 12/28] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E8=A7=84?=
=?UTF-8?q?=E5=88=99=E8=90=BD=E5=9C=B0=E5=99=A8=E4=B8=8E=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=20HLS=20=E8=A7=84=E5=88=99=E5=BA=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 第十二个逻辑单元,见 docs/ad-feedback-timerange-design.md 第 6.2 节。
写落地器时查证了一件关键事实:RuleConfig.getAds() 的唯一消费者是
CustomWebView,即域名黑名单只拦 WebView 请求,拦不住播放器直连的切片请求。
所以域名黑名单对区间广告是无效落地 —— 这正是设计文档 7.2 节把「已在黑名单
却仍被看到」标记为 ALREADY_HANDLED 的原因。
- UserHlsRuleStore:新增用户可写的 HLS 规则来源并接入 HlsRuleConfig。
内置规则来自 APK assets、接口规则来自点播/直播配置,两者都不可写,
而区间反馈需要产出真正能删除切片的规则。写入前先 compile 校验,
非法规则直接拒绝,避免在 HlsRuleConfig 里留下永久报错条目。
- HlsAdRule.createUserRule():字段全私有无 setter,补一个工厂方法。
- DomainReputationClassifier 的落地机制从 HOST_BLACKLIST 改为
HLS_STRUCTURED_RULE,携带 playlistHostSuffixes 收窄作用域;
hostSuffixes + requireCrossDomain 两个信号刚好满足 minimumSignals=2。
取不到 playlist 域名时才退回域名黑名单。
- AdRulePlanApplier:三条落地路径(启用已有规则/HLS 规则/用户规则),
数据全部取自 RulePayload,不解析证据文本。返回 Outcome 三态而非
boolean,让 UI 能区分「跳过」和「失败」。
RulePayloadCompileContractTest 锁定最易断的一环:分类器产出的载荷必须能
组装成 compile() 接受的规则。compile() 要求 minimumSignals 不超过实际信号
数,若不满足会出现「已保存但广告依旧」的静默失败。
Verification: testMobileArm64_v8aDebugUnitTest ad.feedback: 114 tests, 0 failures (RulePayloadCompileContractTest 5 new); compileMobileArm64_v8aDebug OK
Task-Guard: AF4g2-applier
---
.../tv/ad/feedback/AdRulePlanApplier.java | 96 +++++++++++++
.../feedback/DomainReputationClassifier.java | 15 +-
.../android/tv/api/config/HlsRuleConfig.java | 1 +
.../tv/api/config/UserHlsRuleStore.java | 78 +++++++++++
.../com/fongmi/android/tv/bean/HlsAdRule.java | 29 ++++
.../DomainReputationClassifierTest.java | 11 +-
.../RulePayloadCompileContractTest.java | 130 ++++++++++++++++++
7 files changed, 357 insertions(+), 3 deletions(-)
create mode 100644 app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java
create mode 100644 app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java
new file mode 100644
index 0000000000..94e6df8cf4
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java
@@ -0,0 +1,96 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import com.fongmi.android.tv.api.config.HlsRuleStateStore;
+import com.fongmi.android.tv.api.config.UserAdRuleStore;
+import com.fongmi.android.tv.api.config.UserHlsRuleStore;
+import com.fongmi.android.tv.bean.HlsAdRule;
+import com.fongmi.android.tv.bean.UserAdRule;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+
+/**
+ * 把仲裁选出的方案写成真实规则。
+ *
+ * 数据全部取自 {@link RulePayload},不从证据文本里解析 —— 证据是给人看的、
+ * 随语言变化。见设计文档第 6.2 节。
+ */
+public final class AdRulePlanApplier {
+
+ private AdRulePlanApplier() {
+ }
+
+ /** 落地结果。 */
+ public enum Outcome {
+ /** 已写入并立即生效。 */
+ APPLIED,
+ /** 方案不可落地或数据为空。 */
+ SKIPPED,
+ /** 规则非法或写入失败。 */
+ FAILED
+ }
+
+ /**
+ * @param plan 仲裁首选方案
+ * @param siteKey 当前站点,用于收窄用户规则作用域
+ */
+ public static Outcome apply(AdAttribution plan, String siteKey) {
+ if (plan == null || !plan.actionable()) return Outcome.SKIPPED;
+ try {
+ return switch (plan.remediation()) {
+ case ENABLE_EXISTING_RULE -> enableExisting(plan.payload());
+ case HLS_STRUCTURED_RULE -> saveHlsRule(plan, siteKey);
+ case HOST_BLACKLIST -> saveUserRule(plan, siteKey, true);
+ case URL_REGEX_RULE -> saveUserRule(plan, siteKey, false);
+ default -> Outcome.SKIPPED;
+ };
+ } catch (RuntimeException e) {
+ return Outcome.FAILED;
+ }
+ }
+
+ private static Outcome enableExisting(RulePayload payload) {
+ if (payload.ruleKey().isEmpty()) return Outcome.SKIPPED;
+ HlsRuleStateStore.setEnabled(payload.ruleKey(), true);
+ return Outcome.APPLIED;
+ }
+
+ /**
+ * 写入一条用户 HLS 规则。这是唯一能真正删除广告切片的机制 ——
+ * 域名黑名单只在 WebView 层生效,不拦播放器直连的切片请求。
+ */
+ private static Outcome saveHlsRule(AdAttribution plan, String siteKey) {
+ RulePayload payload = plan.payload();
+ if (payload.hosts().isEmpty() && payload.regex().isEmpty()) return Outcome.SKIPPED;
+ // 没有作用域的规则会跨站点生效,HlsAdRule.compile() 也会拒绝
+ if (payload.playlistHostSuffixes().isEmpty()) return Outcome.SKIPPED;
+ HlsAdRule rule = HlsAdRule.createUserRule(
+ UUID.randomUUID().toString(), name(plan, siteKey),
+ payload.playlistHostSuffixes(), payload.hosts(), payload.regex(),
+ payload.hasDurationRange() ? payload.durationMin() : null,
+ payload.hasDurationRange() ? payload.durationMax() : null,
+ payload.requireDiscontinuity(), payload.requireCrossDomain(),
+ Math.max(2, payload.minimumSignals()));
+ return UserHlsRuleStore.add(rule) ? Outcome.APPLIED : Outcome.FAILED;
+ }
+
+ private static Outcome saveUserRule(AdAttribution plan, String siteKey, boolean hostMode) {
+ RulePayload payload = plan.payload();
+ List values = hostMode ? payload.hosts() : payload.regex();
+ if (values.isEmpty()) return Outcome.SKIPPED;
+ UserAdRule rule = UserAdRule.createManual(name(plan, siteKey));
+ rule.setSource(UserAdRule.SOURCE_AI);
+ rule.setSiteKey(siteKey);
+ if (hostMode) rule.setHosts(values);
+ else rule.setRegex(values);
+ UserAdRuleStore.add(rule);
+ return Outcome.APPLIED;
+ }
+
+ private static String name(AdAttribution plan, String siteKey) {
+ String site = siteKey == null || siteKey.isBlank() ? "unknown" : siteKey;
+ return String.format(Locale.US, "区间反馈 %s %s",
+ site, plan.category().name().toLowerCase(Locale.US).replace('_', '-'));
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java
index bfacee1a63..6db88411b6 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifier.java
@@ -90,9 +90,20 @@ public final class DomainReputationClassifier {
}
evidenceLines.add("起点来源:" + evidence.startOrigin());
+ // 落地方式是 HLS 结构化规则而不是域名黑名单:黑名单只在 WebView
+ // 请求拦截生效(RuleConfig.getAds() 的唯一消费者是 CustomWebView),
+ // 拦不住播放器直连的切片请求。跨域按 foreignHosts 的定义天然成立,
+ // 与 hostSuffixes 合起来正好满足 minimumSignals=2。
+ List scope = evidence.playlistHost().isEmpty()
+ ? List.of() : List.of(evidence.playlistHost());
+ RemediationKind remediation = scope.isEmpty()
+ ? RemediationKind.HOST_BLACKLIST : RemediationKind.HLS_STRUCTURED_RULE;
+ RulePayload payload = scope.isEmpty()
+ ? RulePayload.ofHosts(List.copyOf(foreignHosts))
+ : RulePayload.ofHlsRule(scope, List.copyOf(foreignHosts),
+ Double.NaN, Double.NaN, false, true, 2);
return new AdAttribution(CHANNEL_ID, AdCategory.THIRD_PARTY_CDN_SEGMENT,
- confidence, RiskLevel.LOW, evidenceLines, RemediationKind.HOST_BLACKLIST,
- RulePayload.ofHosts(List.copyOf(foreignHosts)));
+ confidence, RiskLevel.LOW, evidenceLines, remediation, payload);
}
/** 区间内不属于 playlist 域名的切片 host,去重保序。 */
diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java b/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java
index 27964a31a0..767754cbf1 100644
--- a/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java
+++ b/app/src/main/java/com/fongmi/android/tv/api/config/HlsRuleConfig.java
@@ -45,6 +45,7 @@ public final class HlsRuleConfig {
compileBuiltin(builtin, overrides, compiled, summaries);
compileExternal("vod", VodConfig.get().getConfig().getUrl(), VodConfig.get().getHlsRules(), overrides, compiled, summaries);
compileExternal("live", LiveConfig.get().getConfig().getUrl(), LiveConfig.get().getHlsRules(), overrides, compiled, summaries);
+ compileExternal("user", "local", UserHlsRuleStore.load(), overrides, compiled, summaries);
rules = List.copyOf(compiled);
entries = List.copyOf(summaries);
dirty = false;
diff --git a/app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java b/app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java
new file mode 100644
index 0000000000..d06a2c63e3
--- /dev/null
+++ b/app/src/main/java/com/fongmi/android/tv/api/config/UserHlsRuleStore.java
@@ -0,0 +1,78 @@
+package com.fongmi.android.tv.api.config;
+
+import com.fongmi.android.tv.App;
+import com.fongmi.android.tv.bean.HlsAdRule;
+import com.github.catvod.utils.Prefers;
+import com.google.gson.reflect.TypeToken;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 用户自建的 HLS 结构化规则。
+ *
+ * 内置规则来自 APK assets、接口规则来自点播/直播配置,两者都不可写;
+ * 而区间反馈需要产出真正能删除广告切片的规则 —— {@code RuleConfig.getAds()}
+ * 的唯一消费者是 {@code CustomWebView},只拦 WebView 请求,不拦播放器直连的
+ * 切片请求。因此新增这条用户可写的来源。
+ */
+public final class UserHlsRuleStore {
+
+ private static final String PREF_KEY = "user_hls_rules";
+ private static final Type LIST_TYPE = new TypeToken>() {}.getType();
+ /** 上限防止规则库无限膨胀拖慢每次 manifest 匹配。 */
+ private static final int MAX_RULES = 200;
+
+ private UserHlsRuleStore() {
+ }
+
+ public static synchronized List load() {
+ try {
+ List rules = App.gson().fromJson(Prefers.getString(PREF_KEY, "[]"), LIST_TYPE);
+ return rules == null ? new ArrayList<>() : rules;
+ } catch (Throwable e) {
+ return new ArrayList<>();
+ }
+ }
+
+ public static synchronized void save(List rules) {
+ List value = rules == null ? new ArrayList<>() : new ArrayList<>(rules);
+ while (value.size() > MAX_RULES) value.remove(0);
+ Prefers.put(PREF_KEY, App.gson().toJson(value));
+ HlsRuleConfig.invalidate();
+ }
+
+ /**
+ * 追加一条规则。写入前先 compile 校验,非法规则直接拒绝 ——
+ * 否则会在 HlsRuleConfig 里变成一条永久报错的条目。
+ *
+ * @return 是否写入成功
+ */
+ public static synchronized boolean add(HlsAdRule rule) {
+ if (rule == null) return false;
+ try {
+ rule.compile();
+ } catch (RuntimeException e) {
+ return false;
+ }
+ List rules = load();
+ if (rules.stream().anyMatch(existing -> existing.getId().equals(rule.getId()))) return false;
+ rules.add(rule);
+ save(rules);
+ return true;
+ }
+
+ public static synchronized boolean delete(String id) {
+ if (id == null || id.isBlank()) return false;
+ List rules = load();
+ boolean removed = rules.removeIf(rule -> id.equals(rule.getId()));
+ if (removed) save(rules);
+ return removed;
+ }
+
+ public static synchronized void clear() {
+ Prefers.put(PREF_KEY, "[]");
+ HlsRuleConfig.invalidate();
+ }
+}
diff --git a/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java b/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java
index c0c55a209a..8bb774fe61 100644
--- a/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java
+++ b/app/src/main/java/com/fongmi/android/tv/bean/HlsAdRule.java
@@ -47,6 +47,35 @@ public class HlsAdRule {
return items == null ? Collections.emptyList() : items;
}
+ /**
+ * 构造一条用户规则。作用域 {@code playlistHostSuffixes} 必填:
+ * {@link #compile()} 会拒绝没有作用域的规则,避免规则跨站点生效。
+ */
+ public static HlsAdRule createUserRule(String id, String name,
+ List playlistHostSuffixes,
+ List hostSuffixes,
+ List segmentUrlRegex,
+ Double minDuration, Double maxDuration,
+ boolean requireDiscontinuity,
+ boolean requireCrossDomain,
+ int minimumSignals) {
+ HlsAdRule rule = new HlsAdRule();
+ rule.id = id;
+ rule.name = name;
+ rule.version = 1;
+ rule.enabledByDefault = true;
+ rule.enabled = Boolean.TRUE;
+ rule.playlistHostSuffixes = playlistHostSuffixes;
+ rule.hostSuffixes = hostSuffixes;
+ rule.segmentUrlRegex = segmentUrlRegex;
+ rule.minDuration = minDuration;
+ rule.maxDuration = maxDuration;
+ rule.requireDiscontinuity = requireDiscontinuity;
+ rule.requireCrossDomain = requireCrossDomain;
+ rule.minimumSignals = minimumSignals;
+ return rule;
+ }
+
public String getId() {
return id == null ? "" : id;
}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java
index f4000a7759..faf4f2e45f 100644
--- a/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/DomainReputationClassifierTest.java
@@ -53,10 +53,19 @@ public class DomainReputationClassifierTest {
assertNotNull(attribution);
assertEquals(AdCategory.THIRD_PARTY_CDN_SEGMENT, attribution.category());
- assertEquals(RemediationKind.HOST_BLACKLIST, attribution.remediation());
+ // 域名黑名单只在 WebView 层生效,拦不住播放器直连的切片;
+ // 要真正删掉这些切片必须落成 HLS 结构化规则
+ assertEquals(RemediationKind.HLS_STRUCTURED_RULE, attribution.remediation());
assertEquals(RiskLevel.LOW, attribution.risk());
assertEquals(DomainReputationClassifier.CONFIDENCE_UNKNOWN_HOST,
attribution.confidence(), 0.0001f);
+ // 规则必须限定到本站 playlist 域名,否则会污染其他站点
+ assertEquals(List.of(PLAYLIST_HOST), attribution.payload().playlistHostSuffixes());
+ assertEquals(List.of("ad-cdn.other.com"), attribution.payload().hosts());
+ // hostSuffixes + requireCrossDomain 两个信号,满足 minimumSignals >= 2
+ assertTrue(attribution.payload().requireCrossDomain());
+ assertEquals(2, attribution.payload().minimumSignals());
+ assertTrue(attribution.actionable());
}
@Test
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java
new file mode 100644
index 0000000000..fd4093c95f
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java
@@ -0,0 +1,130 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.fongmi.android.tv.bean.HlsAdRule;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Test;
+
+/**
+ * 契约测试:分类器产出的 {@link RulePayload} 必须能组装成一条
+ * {@code HlsAdRule.compile()} 接受的规则。
+ *
+ * 这是整条链路最容易断的地方 —— 规则写进 UserHlsRuleStore 却编译失败时,
+ * 用户看到「已保存」但广告依旧存在,且只会在 HlsRuleConfig 里留下一条报错条目。
+ * compile() 的硬约束是 minimumSignals 不得超过实际信号数。
+ */
+public class RulePayloadCompileContractTest {
+
+ /** 把载荷按 AdRulePlanApplier 的方式组装成规则并编译。 */
+ private static void assertCompiles(String label, RulePayload payload) {
+ assertTrue(label + ": 载荷必须带作用域", !payload.playlistHostSuffixes().isEmpty());
+ HlsAdRule rule = HlsAdRule.createUserRule(
+ "test-id", "test", payload.playlistHostSuffixes(),
+ payload.hosts(), payload.regex(),
+ payload.hasDurationRange() ? payload.durationMin() : null,
+ payload.hasDurationRange() ? payload.durationMax() : null,
+ payload.requireDiscontinuity(), payload.requireCrossDomain(),
+ Math.max(2, payload.minimumSignals()));
+ try {
+ assertNotNull(label, rule.compile());
+ } catch (RuntimeException e) {
+ fail(label + ": 规则无法编译 -> " + e.getMessage());
+ }
+ }
+
+ private static AdIntervalEvidence evidence(List inside, List outside,
+ boolean bounded, boolean crossDomain) {
+ return new AdIntervalEvidence(
+ 80_000, 100_000, StartOrigin.USER_MARKED,
+ "site", "站点", "剧名", "线路", "第 1 集",
+ "v.example.com", "/play/index.m3u8", true,
+ inside, outside, bounded, crossDomain, List.of(), false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+ }
+
+ private static List segments(int from, int count, String host,
+ String pathPrefix, double duration,
+ boolean discontinuityOnFirst) {
+ List facts = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ facts.add(new SegmentFact(from + i, host, pathPrefix + (from + i) + ".ts",
+ duration, discontinuityOnFirst && i == 0));
+ }
+ return facts;
+ }
+
+ @Test
+ public void crossDomainWithDiscontinuityAndDurationOutlierCompiles() {
+ AdIntervalEvidence evidence = evidence(
+ segments(10, 3, "ad-cdn.other.com", "/seg/", 6.4, true),
+ segments(0, 10, "v.example.com", "/seg/", 8.0, false),
+ true, true);
+
+ AdAttribution plan = HlsSegmentClassifier.classify(evidence);
+
+ assertNotNull("三信号命中应产出方案", plan);
+ assertCompiles("跨域+断点+时长离群", plan.payload());
+ }
+
+ @Test
+ public void pathHintOnlyIntervalStillCompiles() {
+ // 同域、时长一致,只有路径特征命中:可编码信号数为 1,
+ // 而落地时 minimumSignals 被抬到 2 —— compile() 会因此拒绝。
+ AdIntervalEvidence evidence = evidence(
+ segments(10, 3, "v.example.com", "/ads/", 8.0, false),
+ segments(0, 10, "v.example.com", "/seg/", 8.0, false),
+ false, false);
+
+ AdAttribution plan = HlsSegmentClassifier.classify(evidence);
+
+ // 置信度 0.15 低于门槛,通道应弃权而不是产出无法编译的规则
+ assertTrue("单一弱信号必须弃权", plan == null);
+ }
+
+ @Test
+ public void discontinuityPlusPathHintCompiles() {
+ AdIntervalEvidence evidence = evidence(
+ segments(10, 3, "v.example.com", "/ads/", 8.0, true),
+ segments(0, 10, "v.example.com", "/seg/", 8.0, false),
+ true, false);
+
+ AdAttribution plan = HlsSegmentClassifier.classify(evidence);
+
+ assertNotNull("断点+路径特征应产出方案", plan);
+ assertCompiles("断点+路径特征", plan.payload());
+ }
+
+ @Test
+ public void domainChannelPayloadCompiles() {
+ AdIntervalEvidence evidence = evidence(
+ segments(10, 3, "ad-cdn.other.com", "/seg/", 6.4, false),
+ segments(0, 10, "v.example.com", "/seg/", 8.0, false),
+ false, true);
+
+ AdAttribution plan = DomainReputationClassifier.classify(evidence,
+ new DomainReputationClassifier.Input(
+ List.of(), List.of("v.example.com"), List.of(), ""));
+
+ assertNotNull("非本站域名应产出方案", plan);
+ assertCompiles("域名通道", plan.payload());
+ }
+
+ @Test
+ public void durationOutlierWithCrossDomainCompiles() {
+ AdIntervalEvidence evidence = evidence(
+ segments(10, 4, "ad-cdn.other.com", "/seg/", 6.4, false),
+ segments(0, 10, "v.example.com", "/seg/", 8.0, false),
+ false, true);
+
+ AdAttribution plan = HlsSegmentClassifier.classify(evidence);
+
+ assertNotNull("跨域+时长离群应产出方案", plan);
+ assertCompiles("跨域+时长离群", plan.payload());
+ }
+}
From 1bfa7e0ffc532c3bf29c18e1a599deb8e318949a Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sun, 30 Aug 2026 01:55:21 +0800
Subject: [PATCH 13/28] =?UTF-8?q?=E6=8E=A5=E7=BA=BF=EF=BC=9A=E4=B8=89?=
=?UTF-8?q?=E7=AB=AF=E3=80=8C=E6=9C=89=E5=B9=BF=E5=91=8A=E3=80=8D=E6=8C=89?=
=?UTF-8?q?=E9=92=AE=E6=94=B9=E7=94=A8=E5=8C=BA=E9=97=B4=E5=8F=8D=E9=A6=88?=
=?UTF-8?q?=E4=B8=8E=E5=A4=9A=E9=80=9A=E9=81=93=E5=BD=92=E5=9B=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 收尾,见 docs/ad-feedback-timerange-design.md 第 4、5.2 节。
这是本系列唯一改变用户可见行为的一步。
三端(leanback / mobile / TmdbDetail)各自那份近乎逐字重复的
submitAdFeedback / buildAdDetectionRequest / enrichRequestWithM3u8Evidence /
onAdDetectionResult 全部删除,改为共用 AdFeedbackController +
AdFeedbackHostAdapter,每端只留 Playback/Metadata/Ui 三个内部类适配。
行为变化:
- 按钮可见性放宽:去掉 isAiConfigReady() && isAiAdDetection() 硬门槛,
改为 isAdblock() && 非直播 && 时长已知。不配 AI 也能用,因为本地归因
通道(切片结构/域名/既有规则)不依赖 AI。
- 短按:以当前位置为终点,按 DISCONTINUITY → 跨域切换 → 音频候选 →
固定窗口逐级回溯推断起点。
- 长按:进入标记模式打起点,再次长按取消,之后短按确定终点。
- 提交后立即跳过该区间并显示第一屏,归因完成后原地替换为第二屏。
- 确认保存时经 AdRulePlanApplier 落成真实规则,三态结果分别提示。
- TmdbDetail 换源换集时作废标记模式并清掉上一集的切片证据缓存。
同步更新 TmdbDetailActivityLayoutTest 的源码断言:原断言锁定了 AI 硬门槛
与 AiAdDetectionService 调用,现改为锁定新契约,并加反向断言确保重复的
per-activity 请求构造器不会回归。
Verification: compileMobileArm64_v8aDebug + compileLeanbackArm64_v8aDebug OK; testMobileArm64_v8aDebugUnitTest ui.activity 348 tests 0 failures; full suite 4002 tests with 5 pre-existing failures (MpvPreloadController/FfmpegVc1Support/MultiThreadProxyPlayerUiSource) verified present on base commit via stash
Task-Guard: AF4h-wiring
---
.../android/tv/ui/activity/VideoActivity.java | 217 +++++++++++------
.../tv/ui/activity/TmdbDetailActivity.java | 210 +++++++++++------
app/src/main/res/values-zh-rCN/strings.xml | 3 +
app/src/main/res/values-zh-rTW/strings.xml | 3 +
app/src/main/res/values/strings.xml | 3 +
.../android/tv/ui/activity/VideoActivity.java | 219 +++++++++++-------
.../TmdbDetailActivityLayoutTest.java | 25 +-
7 files changed, 452 insertions(+), 228 deletions(-)
diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
index d70b7a7a59..4fe1295f3a 100644
--- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
+++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
@@ -46,8 +46,11 @@ import com.fongmi.android.tv.api.DanmakuApi;
import com.fongmi.android.tv.api.SiteApi;
import com.fongmi.android.tv.api.config.AdBlockStatsStore;
import com.fongmi.android.tv.api.config.VodConfig;
-import com.fongmi.android.tv.bean.AdDetectionRequest;
-import com.fongmi.android.tv.bean.AdDetectionResult;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackController;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackSession;
+import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier;
+import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog;
import com.fongmi.android.tv.bean.AiConfig;
import com.fongmi.android.tv.bean.Danmaku;
import com.fongmi.android.tv.bean.Episode;
@@ -80,7 +83,6 @@ import com.fongmi.android.tv.player.PlayerHelper;
import com.fongmi.android.tv.player.PlayerManager;
import com.fongmi.android.tv.player.lut.LutPreset;
import com.fongmi.android.tv.player.lut.LutStore;
-import com.fongmi.android.tv.service.AiAdDetectionService;
import com.fongmi.android.tv.service.PlaybackService;
import com.fongmi.android.tv.service.IntroSkipService;
import com.fongmi.android.tv.service.OmdbService;
@@ -391,7 +393,11 @@ private long mInitialPlaybackPosition = C.TIME_UNSET;
private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.recommendationExecutor());
private final Task.Scope mTmdbRatingTasks = new Task.Scope(Task.recommendationExecutor());
private String mTmdbRatingContextKey = "";
- private int mAdFeedbackGeneration;
+ private AdFeedbackController mAdFeedback;
+ private AdFeedbackHostAdapter mAdFeedbackHost;
+ private AdFeedbackDialog mAdFeedbackDialog;
+ /** 标记模式下已打的起点,-1 表示未进入标记模式。 */
+ private long mAdMarkStartMs = -1;
// TMDB 模式相关字段
private com.fongmi.android.tv.ui.helper.TmdbUIAdapter mTmdbUIAdapter;
@@ -1410,6 +1416,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET;
mBinding.control.action.danmaku.setOnClickListener(guarded(this::onDanmaku));
mBinding.control.action.danmaku.setOnLongClickListener(view -> onDanmakuToggle());
mBinding.control.action.adFeedback.setOnClickListener(view -> onAdFeedback());
+ mBinding.control.action.adFeedback.setOnLongClickListener(view -> onAdFeedbackLongPress());
mBinding.control.action.opening.setOnClickListener(guarded(this::onOpening));
if (mBinding.control.action.immersiveAudio != null) mBinding.control.action.immersiveAudio.setOnClickListener(view -> toggleImmersiveAudioMode());
if (mBinding.control.action.cast != null) mBinding.control.action.cast.setOnClickListener(view -> onCast());
@@ -3985,7 +3992,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET;
return;
}
if (!isAdFeedbackEnabled()) {
- Notify.show(R.string.ad_feedback_ai_disabled);
+ Notify.show(R.string.ad_interval_invalid);
return;
}
hideControl();
@@ -3993,15 +4000,10 @@ private long mInitialPlaybackPosition = C.TIME_UNSET;
}
private boolean isAdFeedbackEnabled() {
- // 功能开关 + 仅支持解析的格式(HLS/m3u8)才可反馈,因为去广分析依赖切片列表
- return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat();
- }
-
- private boolean isAdFeedbackSupportedFormat() {
- if (player() == null) return false;
- String url = player().getUrl();
- if (TextUtils.isEmpty(url)) return false;
- return com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(url);
+ // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。
+ // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。
+ if (player() == null || TextUtils.isEmpty(player().getUrl())) return false;
+ return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0;
}
private void setAdFeedbackVisible() {
@@ -4009,76 +4011,139 @@ private long mInitialPlaybackPosition = C.TIME_UNSET;
applyActionButtonVisibility();
}
+ private AdFeedbackController adFeedback() {
+ if (mAdFeedback == null) {
+ mAdFeedbackHost = new AdFeedbackHostAdapter(
+ new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi());
+ mAdFeedback = new AdFeedbackController(mAdFeedbackHost);
+ }
+ return mAdFeedback;
+ }
+
+ /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */
private void submitAdFeedback() {
- AdDetectionRequest request = buildAdDetectionRequest();
- if (request == null) {
- Notify.show(R.string.ad_feedback_no_url);
- return;
+ AdFeedbackController controller = adFeedback();
+ AdBlockStatsStore.recordFeedback(getKey());
+ AdFeedbackSession session;
+ if (mAdMarkStartMs >= 0) {
+ long start = mAdMarkStartMs;
+ mAdMarkStartMs = -1;
+ session = controller.onMarkedInterval(start, player().getPosition());
+ } else {
+ session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence());
}
- // 记录 AI 反馈统计
- AdBlockStatsStore.recordFeedback(request.getSiteKey());
-
- Notify.show(R.string.ad_feedback_analyzing);
- int generation = ++mAdFeedbackGeneration;
- AiConfig config = AiConfig.objectFrom(Setting.getAiConfig());
- Task.execute(() -> {
- enrichRequestWithM3u8Evidence(request);
- AdDetectionResult result = new AiAdDetectionService(config).analyze(request);
- runOnUiThread(() -> {
- if (isFinishing() || isDestroyed() || generation != mAdFeedbackGeneration) return;
- onAdDetectionResult(request, result);
- });
- });
+ if (session == null) Notify.show(R.string.ad_interval_invalid);
}
- private AdDetectionRequest buildAdDetectionRequest() {
- if (player() == null || TextUtils.isEmpty(player().getUrl())) return null;
- String url = player().getUrl();
- Uri uri = Uri.parse(url);
- AdDetectionRequest request = new AdDetectionRequest();
+ /** 长按:打起点进入标记模式,再次长按取消。 */
+ private boolean onAdFeedbackLongPress() {
+ if (!isAdFeedbackEnabled()) return false;
+ if (mAdMarkStartMs >= 0) {
+ mAdMarkStartMs = -1;
+ Notify.show(R.string.ad_interval_mark_cancelled);
+ } else {
+ mAdMarkStartMs = player().getPosition();
+ Notify.show(R.string.ad_interval_mark_start);
+ }
+ return true;
+ }
+
+ private void showAdFeedbackSession(AdFeedbackSession session) {
+ if (isFinishing() || isDestroyed()) return;
+ if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan);
+ mAdFeedbackDialog.show(session);
+ }
+
+ private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) {
Site site = getSite();
- request.setSiteKey(site == null ? getKey() : site.getKey());
- request.setSiteName(site == null ? "" : site.getName());
- request.setVodName(mHistory == null ? getName() : mHistory.getVodName());
- request.setFlagName(getFlag() == null ? "" : getFlag().getFlag());
- request.setEpisodeName(getEpisode() == null ? "" : getEpisode().getName());
- request.setUrlHost(uri.getHost());
- request.setUrlPath(uri.getPath());
- return request;
- }
-
- private void enrichRequestWithM3u8Evidence(AdDetectionRequest request) {
- if (player() == null || TextUtils.isEmpty(player().getUrl())) return;
- String url = player().getUrl();
- if (!url.contains(".m3u8")) return;
- try {
- java.util.Map headers = player().getHeaders();
- com.fongmi.android.tv.bean.M3u8Evidence evidence = com.fongmi.android.tv.utils.M3u8Parser.parse(url, headers);
- request.setEvidence(evidence);
- } catch (Exception e) {
- // Ignore parsing failures
- }
- }
-
- private void onAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) {
- // 记录 AI 分析结果统计
- AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError());
-
- if (result == null || result.isError()) {
- Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage());
- return;
- }
- if (result.isEmpty()) {
- Notify.show(R.string.ad_feedback_no_ad);
- return;
- }
- com.fongmi.android.tv.ui.dialog.AdRulePreviewDialog.create(result).show(this, confirmedResult -> {
- UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey());
- com.fongmi.android.tv.api.config.UserAdRuleStore.add(rule);
- Notify.show(R.string.ad_feedback_saved);
+ AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply(
+ plan, site == null ? getKey() : site.getKey());
+ Notify.show(switch (outcome) {
+ case APPLIED -> R.string.ad_interval_rule_saved;
+ case SKIPPED -> R.string.ad_interval_rule_skipped;
+ case FAILED -> R.string.ad_interval_rule_failed;
});
}
+ private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback {
+ @Override
+ public long positionMs() {
+ return player() == null ? 0 : player().getPosition();
+ }
+
+ @Override
+ public long durationMs() {
+ return player() == null ? 0 : player().getDuration();
+ }
+
+ @Override
+ public String playUrl() {
+ return player() == null ? "" : player().getUrl();
+ }
+
+ @Override
+ public java.util.Map headers() {
+ return player() == null ? java.util.Map.of() : player().getHeaders();
+ }
+
+ @Override
+ public boolean hls() {
+ return player() != null
+ && com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(player().getUrl());
+ }
+
+ @Override
+ public boolean skipInterval(long startMs, long endMs, String feedbackId) {
+ return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId);
+ }
+ }
+
+ private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata {
+ @Override
+ public String siteKey() {
+ Site site = getSite();
+ return site == null ? getKey() : site.getKey();
+ }
+
+ @Override
+ public String siteName() {
+ Site site = getSite();
+ return site == null ? "" : site.getName();
+ }
+
+ @Override
+ public String vodName() {
+ return mHistory == null ? getName() : mHistory.getVodName();
+ }
+
+ @Override
+ public String flagName() {
+ return getFlag() == null ? "" : getFlag().getFlag();
+ }
+
+ @Override
+ public String episodeName() {
+ return getEpisode() == null ? "" : getEpisode().getName();
+ }
+ }
+
+ private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui {
+ @Override
+ public void runBackground(Runnable task) {
+ Task.execute(task);
+ }
+
+ @Override
+ public void runOnUi(Runnable task) {
+ runOnUiThread(task);
+ }
+
+ @Override
+ public void showSession(AdFeedbackSession session) {
+ showAdFeedbackSession(session);
+ }
+ }
+
private boolean onDanmakuToggle() {
DanmakuSetting.putShow(!DanmakuSetting.isShow());
applyDanmakuState();
diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java
index 59a94b70a0..65c1be4d59 100644
--- a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java
+++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java
@@ -67,8 +67,11 @@ import com.fongmi.android.tv.api.SiteApi;
import com.fongmi.android.tv.api.config.AdBlockStatsStore;
import com.fongmi.android.tv.api.config.UserAdRuleStore;
import com.fongmi.android.tv.api.config.VodConfig;
-import com.fongmi.android.tv.bean.AdDetectionRequest;
-import com.fongmi.android.tv.bean.AdDetectionResult;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackController;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackSession;
+import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier;
+import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog;
import com.fongmi.android.tv.bean.AiConfig;
import com.fongmi.android.tv.bean.Danmaku;
import com.fongmi.android.tv.bean.Episode;
@@ -110,7 +113,6 @@ import com.fongmi.android.tv.player.IntroSkipPlayback;
import com.fongmi.android.tv.player.PlayerManager;
import com.fongmi.android.tv.player.PlayerHelper;
import com.fongmi.android.tv.player.exo.MediaSourceFactory;
-import com.fongmi.android.tv.service.AiAdDetectionService;
import com.fongmi.android.tv.service.AiEpisodeSeasonService;
import com.fongmi.android.tv.service.AiRecommendationService;
import com.fongmi.android.tv.service.PersonalRecommendationService;
@@ -408,7 +410,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
/** 本次取详情的起始时刻,用来认出「猫源开内嵌页」是不是自己这次导航触发的。volatile:加载在后台线程发起,事件在主线程读。 */
private volatile long detailLoadStart;
private int inlinePlaybackGeneration;
- private int mAdFeedbackGeneration;
+ private AdFeedbackController mAdFeedback;
+ private AdFeedbackHostAdapter mAdFeedbackHost;
+ private AdFeedbackDialog mAdFeedbackDialog;
+ /** 标记模式下已打的起点,-1 表示未进入标记模式。 */
+ private long mAdMarkStartMs = -1;
private int tmdbDialogGeneration;
private AiEpisodeSeasonService aiSeasonService;
private AlertDialog aiSeasonLoadingDialog;
@@ -1195,6 +1201,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
setupInlineControlFocus();
setupInlineFocusNavigation();
binding.playerAdFeedback.setOnClickListener(guarded(this::onInlineAdFeedback));
+ binding.playerAdFeedback.setOnLongClickListener(view -> onInlineAdFeedbackLongPress());
binding.playerMultiThreadProxy.setOnClickListener(guarded(this::showInlineMultiThreadProxy));
binding.playerSearch.setOnClickListener(view -> openInlineSourceSearch());
binding.playerSearch.setOnLongClickListener(view -> openGlobalSourceSearch());
@@ -1253,6 +1260,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
detailActionView(R.id.ending, View.class).setOnLongClickListener(view -> resetInlineEnding());
detailActionView(R.id.danmaku, View.class).setOnClickListener(guarded(this::showInlineDanmaku));
detailActionView(R.id.adFeedback, View.class).setOnClickListener(guarded(this::onInlineAdFeedback));
+ detailActionView(R.id.adFeedback, View.class).setOnLongClickListener(view -> onInlineAdFeedbackLongPress());
detailActionView(R.id.chapter, View.class).setOnClickListener(guarded(this::showInlineTitle));
detailActionView(R.id.episodes, View.class).setOnClickListener(guarded(this::showInlineEpisodes));
setupMobileInlineParse();
@@ -6886,7 +6894,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
}
private void stopInlinePlayerForReload() {
- mAdFeedbackGeneration++;
+ // 换源换集:作废标记模式并清掉上一集的切片证据缓存
+ mAdMarkStartMs = -1;
+ if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence();
subtitlePlaybackSession.stop(this);
inlineStartPosition = C.TIME_UNSET;
inlineStartPositionApplied = false;
@@ -8289,7 +8299,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
return;
}
if (!isInlineAdFeedbackEnabled()) {
- Notify.show(R.string.ad_feedback_ai_disabled);
+ Notify.show(R.string.ad_interval_invalid);
return;
}
hideInlineControls();
@@ -8297,79 +8307,145 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
}
private boolean isInlineAdFeedbackEnabled() {
- return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection()
- && isInlineAdFeedbackSupportedFormat();
+ // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。
+ // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。
+ if (player() == null || TextUtils.isEmpty(player().getUrl())) return false;
+ return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0;
}
- private boolean isInlineAdFeedbackSupportedFormat() {
- return player() != null && !TextUtils.isEmpty(player().getUrl())
- && MediaSourceFactory.isHlsUrl(player().getUrl());
+ private AdFeedbackController adFeedback() {
+ if (mAdFeedback == null) {
+ mAdFeedbackHost = new AdFeedbackHostAdapter(
+ new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi());
+ mAdFeedback = new AdFeedbackController(mAdFeedbackHost);
+ }
+ return mAdFeedback;
}
+ /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */
private void submitInlineAdFeedback() {
- AdDetectionRequest request = buildInlineAdDetectionRequest();
- if (request == null) {
- Notify.show(R.string.ad_feedback_no_url);
- return;
+ AdFeedbackController controller = adFeedback();
+ AdBlockStatsStore.recordFeedback(getKeyText());
+ AdFeedbackSession session;
+ if (mAdMarkStartMs >= 0) {
+ long start = mAdMarkStartMs;
+ mAdMarkStartMs = -1;
+ session = controller.onMarkedInterval(start, player().getPosition());
+ } else {
+ session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence());
}
- AdBlockStatsStore.recordFeedback(request.getSiteKey());
- Notify.show(R.string.ad_feedback_analyzing);
- int generation = ++mAdFeedbackGeneration;
- AiConfig config = AiConfig.objectFrom(Setting.getAiConfig());
- detailTasks.submit(Task.recommendationExecutor(), () -> {
- enrichInlineAdDetectionRequest(request);
- AdDetectionResult result = new AiAdDetectionService(config).analyze(request);
- runOnAliveUi(() -> {
- if (generation != mAdFeedbackGeneration) return;
- onInlineAdDetectionResult(request, result);
- });
- });
+ if (session == null) Notify.show(R.string.ad_interval_invalid);
}
- private AdDetectionRequest buildInlineAdDetectionRequest() {
- if (player() == null || TextUtils.isEmpty(player().getUrl())) return null;
- Uri uri = Uri.parse(player().getUrl());
- AdDetectionRequest request = new AdDetectionRequest();
+ /** 长按:打起点进入标记模式,再次长按取消。 */
+ private boolean onInlineAdFeedbackLongPress() {
+ if (!isInlineAdFeedbackEnabled()) return false;
+ if (mAdMarkStartMs >= 0) {
+ mAdMarkStartMs = -1;
+ Notify.show(R.string.ad_interval_mark_cancelled);
+ } else {
+ mAdMarkStartMs = player().getPosition();
+ Notify.show(R.string.ad_interval_mark_start);
+ }
+ return true;
+ }
+
+ private void showAdFeedbackSession(AdFeedbackSession session) {
+ if (isFinishing() || isDestroyed()) return;
+ if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan);
+ mAdFeedbackDialog.show(session);
+ }
+
+ private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) {
Site site = getCurrentSite();
- History currentHistory = getHistory();
- request.setSiteKey(site == null ? getKeyText() : site.getKey());
- request.setSiteName(site == null ? "" : site.getName());
- request.setVodName(currentHistory == null ? getNameText() : currentHistory.getVodName());
- request.setFlagName(selectedFlag == null ? "" : selectedFlag.getFlag());
- request.setEpisodeName(selectedEpisode == null ? "" : selectedEpisode.getName());
- request.setUrlHost(uri.getHost());
- request.setUrlPath(uri.getPath());
- return request;
- }
-
- private void enrichInlineAdDetectionRequest(AdDetectionRequest request) {
- if (player() == null || TextUtils.isEmpty(player().getUrl())) return;
- String url = player().getUrl();
- if (!url.contains(".m3u8")) return;
- try {
- request.setEvidence(com.fongmi.android.tv.utils.M3u8Parser.parse(url, player().getHeaders()));
- } catch (Exception ignored) {
- // Ignore parsing failures.
- }
- }
-
- private void onInlineAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) {
- AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError());
- if (result == null || result.isError()) {
- Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage());
- return;
- }
- if (result.isEmpty()) {
- Notify.show(R.string.ad_feedback_no_ad);
- return;
- }
- AdRulePreviewDialog.create(result).show(this, confirmedResult -> {
- UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey());
- UserAdRuleStore.add(rule);
- Notify.show(R.string.ad_feedback_saved);
+ AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply(
+ plan, site == null ? getKeyText() : site.getKey());
+ Notify.show(switch (outcome) {
+ case APPLIED -> R.string.ad_interval_rule_saved;
+ case SKIPPED -> R.string.ad_interval_rule_skipped;
+ case FAILED -> R.string.ad_interval_rule_failed;
});
}
+ private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback {
+ @Override
+ public long positionMs() {
+ return player() == null ? 0 : player().getPosition();
+ }
+
+ @Override
+ public long durationMs() {
+ return player() == null ? 0 : player().getDuration();
+ }
+
+ @Override
+ public String playUrl() {
+ return player() == null ? "" : player().getUrl();
+ }
+
+ @Override
+ public java.util.Map headers() {
+ return player() == null ? java.util.Map.of() : player().getHeaders();
+ }
+
+ @Override
+ public boolean hls() {
+ return player() != null && MediaSourceFactory.isHlsUrl(player().getUrl());
+ }
+
+ @Override
+ public boolean skipInterval(long startMs, long endMs, String feedbackId) {
+ return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId);
+ }
+ }
+
+ private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata {
+ @Override
+ public String siteKey() {
+ Site site = getCurrentSite();
+ return site == null ? getKeyText() : site.getKey();
+ }
+
+ @Override
+ public String siteName() {
+ Site site = getCurrentSite();
+ return site == null ? "" : site.getName();
+ }
+
+ @Override
+ public String vodName() {
+ History currentHistory = getHistory();
+ return currentHistory == null ? getNameText() : currentHistory.getVodName();
+ }
+
+ @Override
+ public String flagName() {
+ return selectedFlag == null ? "" : selectedFlag.getFlag();
+ }
+
+ @Override
+ public String episodeName() {
+ return selectedEpisode == null ? "" : selectedEpisode.getName();
+ }
+ }
+
+ private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui {
+ @Override
+ public void runBackground(Runnable task) {
+ detailTasks.submit(Task.recommendationExecutor(), task);
+ }
+
+ @Override
+ public void runOnUi(Runnable task) {
+ runOnAliveUi(task);
+ }
+
+ @Override
+ public void showSession(AdFeedbackSession session) {
+ showAdFeedbackSession(session);
+ }
+ }
+
private void showInlineDanmaku() {
if (service() == null || player().isEmpty()) return;
DanmakuDialog.create().player(player()).identity(getKeyText(), getIdText(), playbackHistoryName(), selectedEpisode == null ? "" : historyEpisodeTitle(selectedEpisode)).show(this);
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 35873279aa..cf7b18a824 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -2495,6 +2495,9 @@
已标记广告起点,到结束处再按一次
已取消标记
当前位置无法反馈广告
+ 规则已保存并生效
+ 该区间没有可保存的规则
+ 规则保存失败
AI/自定义 %1$d 条 · 接口配置 %2$d 条 · 内置 HLS %3$d 条
内置 HLS · v%1$d · %2$s
已启用
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index fe9fb7816e..4d62df1e00 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -2448,6 +2448,9 @@
已標記廣告起點,到結束處再按一次
已取消標記
當前位置無法反饋廣告
+ 規則已儲存並生效
+ 該區間沒有可儲存的規則
+ 規則儲存失敗
AI/自定義 %1$d 條 · 接口配置 %2$d 條 · 內置 HLS %3$d 條
內置 HLS · v%1$d · %2$s
已啓用
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0fc0cb8c5a..996c81fb6b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2162,6 +2162,9 @@
Ad start marked. Press again at the end.
Marking cancelled
Cannot report an ad at this position
+ Rule saved and active
+ Nothing to save for this interval
+ Could not save the rule
AI/custom %1$d · source config %2$d · built-in HLS %3$d
Built-in HLS · v%1$d · %2$s
Enabled
diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
index 7bee81b191..840e037f9e 100644
--- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
+++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
@@ -65,8 +65,11 @@ import com.fongmi.android.tv.api.DanmakuApi;
import com.fongmi.android.tv.api.SiteApi;
import com.fongmi.android.tv.api.config.AdBlockStatsStore;
import com.fongmi.android.tv.api.config.VodConfig;
-import com.fongmi.android.tv.bean.AdDetectionRequest;
-import com.fongmi.android.tv.bean.AdDetectionResult;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackController;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter;
+import com.fongmi.android.tv.ad.feedback.AdFeedbackSession;
+import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier;
+import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog;
import com.fongmi.android.tv.bean.AiConfig;
import com.fongmi.android.tv.bean.CastVideo;
import com.fongmi.android.tv.bean.Danmaku;
@@ -103,7 +106,6 @@ import com.fongmi.android.tv.player.engine.PlaySpec;
import com.fongmi.android.tv.player.lut.LutPreset;
import com.fongmi.android.tv.player.lut.LutSetting;
import com.fongmi.android.tv.player.lut.LutStore;
-import com.fongmi.android.tv.service.AiAdDetectionService;
import com.fongmi.android.tv.service.PlaybackService;
import com.fongmi.android.tv.service.PersonalRecommendationService;
import com.fongmi.android.tv.service.IntroSkipService;
@@ -439,7 +441,11 @@ private int mAudioBackgroundRandomNonce;
private TmdbItem mPendingTmdbSeasonChoice;
private int mPersonalRecommendationGeneration;
private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.recommendationExecutor());
- private int mAdFeedbackGeneration;
+ private AdFeedbackController mAdFeedback;
+ private AdFeedbackHostAdapter mAdFeedbackHost;
+ private AdFeedbackDialog mAdFeedbackDialog;
+ /** 标记模式下已打的起点,-1 表示未进入标记模式。 */
+ private long mAdMarkStartMs = -1;
private final List mTmdbMovedViews = new ArrayList<>();
private boolean pendingLutImport;
private boolean skipPausePiP;
@@ -1483,6 +1489,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco
mBinding.control.action.danmaku.setOnClickListener(guarded(this::onDanmaku));
mBinding.control.action.adFeedback.setOnClickListener(view -> onAdFeedback());
+ mBinding.control.action.adFeedback.setOnLongClickListener(view -> onAdFeedbackLongPress());
mBinding.control.action.episodes.setOnClickListener(view -> onEpisodes());
mBinding.control.action.text.setOnLongClickListener(view -> onTextLong());
mBinding.control.action.speed.setOnLongClickListener(view -> onSpeedLong());
@@ -3050,7 +3057,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco
return;
}
if (!isAdFeedbackEnabled()) {
- Notify.show(R.string.ad_feedback_ai_disabled);
+ Notify.show(R.string.ad_interval_invalid);
return;
}
hideControl();
@@ -8659,15 +8666,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco
}
private boolean isAdFeedbackEnabled() {
- // 功能开关 + 仅支持解析的格式(HLS/m3u8)才可反馈,因为去广分析依赖切片列表
- return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat();
- }
-
- private boolean isAdFeedbackSupportedFormat() {
- if (player() == null) return false;
- String url = player().getUrl();
- if (TextUtils.isEmpty(url)) return false;
- return com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(url);
+ // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。
+ // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。
+ if (player() == null || TextUtils.isEmpty(player().getUrl())) return false;
+ return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0;
}
private void setAdFeedbackVisible() {
@@ -8675,78 +8677,139 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco
applyActionButtonVisibility();
}
+ private AdFeedbackController adFeedback() {
+ if (mAdFeedback == null) {
+ mAdFeedbackHost = new AdFeedbackHostAdapter(
+ new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi());
+ mAdFeedback = new AdFeedbackController(mAdFeedbackHost);
+ }
+ return mAdFeedback;
+ }
+
+ /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */
private void submitAdFeedback() {
- AdDetectionRequest request = buildAdDetectionRequest();
- if (request == null) {
- Notify.show(R.string.ad_feedback_no_url);
- return;
+ AdFeedbackController controller = adFeedback();
+ AdBlockStatsStore.recordFeedback(getKey());
+ AdFeedbackSession session;
+ if (mAdMarkStartMs >= 0) {
+ long start = mAdMarkStartMs;
+ mAdMarkStartMs = -1;
+ session = controller.onMarkedInterval(start, player().getPosition());
+ } else {
+ session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence());
}
- // 记录 AI 反馈统计
- AdBlockStatsStore.recordFeedback(request.getSiteKey());
-
- Notify.show(R.string.ad_feedback_analyzing);
- int generation = ++mAdFeedbackGeneration;
- AiConfig config = AiConfig.objectFrom(Setting.getAiConfig());
- Task.execute(() -> {
- // Parse m3u8 evidence (blocking I/O)
- enrichRequestWithM3u8Evidence(request);
- AdDetectionResult result = new AiAdDetectionService(config).analyze(request);
- runOnUiThread(() -> {
- if (isFinishing() || isDestroyed() || generation != mAdFeedbackGeneration) return;
- onAdDetectionResult(request, result);
- });
- });
+ if (session == null) Notify.show(R.string.ad_interval_invalid);
}
- private AdDetectionRequest buildAdDetectionRequest() {
- if (player() == null || TextUtils.isEmpty(player().getUrl())) return null;
- String url = player().getUrl();
- Uri uri = Uri.parse(url);
- AdDetectionRequest request = new AdDetectionRequest();
+ /** 长按:打起点进入标记模式,再次长按取消。 */
+ private boolean onAdFeedbackLongPress() {
+ if (!isAdFeedbackEnabled()) return false;
+ if (mAdMarkStartMs >= 0) {
+ mAdMarkStartMs = -1;
+ Notify.show(R.string.ad_interval_mark_cancelled);
+ } else {
+ mAdMarkStartMs = player().getPosition();
+ Notify.show(R.string.ad_interval_mark_start);
+ }
+ return true;
+ }
+
+ private void showAdFeedbackSession(AdFeedbackSession session) {
+ if (isFinishing() || isDestroyed()) return;
+ if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan);
+ mAdFeedbackDialog.show(session);
+ }
+
+ private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) {
Site site = getSite();
- request.setSiteKey(site == null ? getKey() : site.getKey());
- request.setSiteName(site == null ? "" : site.getName());
- request.setVodName(mHistory == null ? mBinding.name.getText().toString() : mHistory.getVodName());
- request.setFlagName(getFlag() == null ? "" : getFlag().getFlag());
- request.setEpisodeName(getEpisode() == null ? "" : getEpisode().getName());
- request.setUrlHost(uri.getHost());
- request.setUrlPath(uri.getPath());
- return request;
- }
-
- private void enrichRequestWithM3u8Evidence(AdDetectionRequest request) {
- if (player() == null || TextUtils.isEmpty(player().getUrl())) return;
- String url = player().getUrl();
- if (!url.contains(".m3u8")) return; // Only parse m3u8
- try {
- java.util.Map headers = player().getHeaders();
- com.fongmi.android.tv.bean.M3u8Evidence evidence = com.fongmi.android.tv.utils.M3u8Parser.parse(url, headers);
- request.setEvidence(evidence);
- } catch (Exception e) {
- // Ignore parsing failures
- }
- }
-
- private void onAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) {
- // 记录 AI 分析结果统计
- AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError());
-
- if (result == null || result.isError()) {
- Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage());
- return;
- }
- if (result.isEmpty()) {
- Notify.show(R.string.ad_feedback_no_ad);
- return;
- }
- // Show preview dialog
- com.fongmi.android.tv.ui.dialog.AdRulePreviewDialog.create(result).show(this, confirmedResult -> {
- UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey());
- com.fongmi.android.tv.api.config.UserAdRuleStore.add(rule);
- Notify.show(R.string.ad_feedback_saved);
+ AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply(
+ plan, site == null ? getKey() : site.getKey());
+ Notify.show(switch (outcome) {
+ case APPLIED -> R.string.ad_interval_rule_saved;
+ case SKIPPED -> R.string.ad_interval_rule_skipped;
+ case FAILED -> R.string.ad_interval_rule_failed;
});
}
+ private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback {
+ @Override
+ public long positionMs() {
+ return player() == null ? 0 : player().getPosition();
+ }
+
+ @Override
+ public long durationMs() {
+ return player() == null ? 0 : player().getDuration();
+ }
+
+ @Override
+ public String playUrl() {
+ return player() == null ? "" : player().getUrl();
+ }
+
+ @Override
+ public java.util.Map headers() {
+ return player() == null ? java.util.Map.of() : player().getHeaders();
+ }
+
+ @Override
+ public boolean hls() {
+ return player() != null
+ && com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(player().getUrl());
+ }
+
+ @Override
+ public boolean skipInterval(long startMs, long endMs, String feedbackId) {
+ return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId);
+ }
+ }
+
+ private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata {
+ @Override
+ public String siteKey() {
+ Site site = getSite();
+ return site == null ? getKey() : site.getKey();
+ }
+
+ @Override
+ public String siteName() {
+ Site site = getSite();
+ return site == null ? "" : site.getName();
+ }
+
+ @Override
+ public String vodName() {
+ return mHistory == null ? mBinding.name.getText().toString() : mHistory.getVodName();
+ }
+
+ @Override
+ public String flagName() {
+ return getFlag() == null ? "" : getFlag().getFlag();
+ }
+
+ @Override
+ public String episodeName() {
+ return getEpisode() == null ? "" : getEpisode().getName();
+ }
+ }
+
+ private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui {
+ @Override
+ public void runBackground(Runnable task) {
+ Task.execute(task);
+ }
+
+ @Override
+ public void runOnUi(Runnable task) {
+ runOnUiThread(task);
+ }
+
+ @Override
+ public void showSession(AdFeedbackSession session) {
+ showAdFeedbackSession(session);
+ }
+ }
+
private static class ShortDramaControlItem {
private final View view;
private final ViewGroup parent;
diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java
index a6018e902d..464a4eb478 100644
--- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java
+++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java
@@ -443,15 +443,26 @@ public class TmdbDetailActivityLayoutTest {
assertTrue("wide and mobile inline controls must dispatch the ad feedback action",
source.contains("binding.playerAdFeedback.setOnClickListener(guarded(this::onInlineAdFeedback));")
&& source.contains("detailActionView(R.id.adFeedback, View.class).setOnClickListener(guarded(this::onInlineAdFeedback));"));
- assertTrue("detail inline playback must use the same HLS and AI availability gate as the native player",
+ assertTrue("detail inline ad feedback must only require a seekable VOD timeline, not AI config",
source.contains("private boolean isInlineAdFeedbackEnabled()")
- && source.contains("Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection()")
- && source.contains("MediaSourceFactory.isHlsUrl(player().getUrl())"));
- assertTrue("detail inline playback must submit AI analysis and save confirmed user rules",
+ && source.contains("Setting.isAdblock() && !player().isLive() && player().getDuration() > 0"));
+ assertFalse("AI must no longer gate the ad feedback button",
+ source.contains("Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection()"));
+ assertTrue("detail inline playback must route ad feedback through the shared controller",
source.contains("private void submitInlineAdFeedback()")
- && source.contains("new AiAdDetectionService(config).analyze(request)")
- && source.contains("AdRulePreviewDialog.create(result).show(this, confirmedResult ->")
- && source.contains("UserAdRuleStore.add(rule);"));
+ && source.contains("controller.onMarkedInterval(start, player().getPosition())")
+ && source.contains("controller.onQuickReport(mAdFeedbackHost.cachedEvidence())")
+ && source.contains("new AdFeedbackController(mAdFeedbackHost)"));
+ assertTrue("long press must arm the interval marking mode",
+ source.contains("private boolean onInlineAdFeedbackLongPress()")
+ && source.contains("binding.playerAdFeedback.setOnLongClickListener(view -> onInlineAdFeedbackLongPress());")
+ && source.contains("detailActionView(R.id.adFeedback, View.class).setOnLongClickListener(view -> onInlineAdFeedbackLongPress());"));
+ assertTrue("confirmed plans must be applied through the shared applier",
+ source.contains("AdRulePlanApplier.apply(")
+ && source.contains("case APPLIED -> R.string.ad_interval_rule_saved;"));
+ assertFalse("the duplicated per-activity AI request builder must be gone",
+ source.contains("private AdDetectionRequest buildInlineAdDetectionRequest()")
+ || source.contains("new AiAdDetectionService(config).analyze(request)"));
}
/**
From 75859b5de42ae91b216a36167832bbbe10279417 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sun, 30 Aug 2026 03:15:52 +0800
Subject: [PATCH 14/28] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=94=9F?=
=?UTF-8?q?=E6=88=90=E7=9A=84=20HLS=20=E8=A7=84=E5=88=99=E5=AE=9E=E9=99=85?=
=?UTF-8?q?=E5=88=A0=E4=B8=8D=E6=8E=89=E5=B9=BF=E5=91=8A=E5=88=87=E7=89=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
端到端测试(GenRuleE2ETest)把分类器载荷真的组装成 HlsAdRule 交给
HlsManifestCleaner 跑 30 片 manifest,三个场景全部一片未删。单元测试
只验证了载荷字段与 compile() 通过,覆盖不到运行时评分。
根因是误解了 HlsManifestCleaner 的信号语义:它对每个切片独立累加信号
(HlsManifestCleaner.java:143-149),达到 minimumSignals 即删。因此只能
编码对块内每一片都成立、且有区分度的条件:
- hostSuffixes 原本包含与 playlist 同域的域名,同域条件对每片正片都成立,
会误删正片。现只保留非本站域名。
- requireDiscontinuity 只对广告块首片成立,块内后续片拿不到该信号,
编码进规则会只删首片、留下其余广告。现不编码,断点只用于归因定位区间。
- 新增 pathPatterns:命中的广告目录段经 Pattern.quote 转正则,作为同域
场景下唯一的区分条件。两类区分条件都没有时返回空载荷让通道弃权。
- minimumSignals 从 Math.max(2, ...) 改为 Math.min(2, encoded):
超过实际可编码信号数会让 compile() 抛 Invalid minimum signals,
规则永远写不进去且用户只看到「保存失败」。
- AdRulePlanApplier 不再二次抬高门限,同样原因。
Verification: testMobileArm64_v8aDebugUnitTest ad.feedback: 13 suites 117 tests 0 failures (GenRuleE2ETest 3 new)
Task-Guard: AF5a-e2e-fix
---
.../tv/ad/feedback/AdRulePlanApplier.java | 4 +-
.../tv/ad/feedback/HlsSegmentClassifier.java | 67 ++++++++--
.../android/tv/ad/feedback/RulePayload.java | 12 +-
.../tv/ad/feedback/GenRuleE2ETest.java | 120 ++++++++++++++++++
.../RulePayloadCompileContractTest.java | 2 +-
.../tv/ad/feedback/RulePayloadTest.java | 8 +-
6 files changed, 198 insertions(+), 15 deletions(-)
create mode 100644 app/src/test/java/com/fongmi/android/tv/ad/feedback/GenRuleE2ETest.java
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java
index 94e6df8cf4..04a5eb1f7d 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdRulePlanApplier.java
@@ -71,7 +71,9 @@ public final class AdRulePlanApplier {
payload.hasDurationRange() ? payload.durationMin() : null,
payload.hasDurationRange() ? payload.durationMax() : null,
payload.requireDiscontinuity(), payload.requireCrossDomain(),
- Math.max(2, payload.minimumSignals()));
+ // 门限由分类器按实际可编码信号数算好,不能在这里再抬高 ——
+ // 超过实际信号数会让 compile() 拒绝整条规则
+ payload.minimumSignals());
return UserHlsRuleStore.add(rule) ? Outcome.APPLIED : Outcome.FAILED;
}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java
index 62c2eda652..07efbade05 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/HlsSegmentClassifier.java
@@ -5,6 +5,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.regex.Pattern;
/**
* 基于 HLS 切片结构的归因通道。零新增运行时成本:全部输入来自
@@ -87,16 +88,28 @@ public final class HlsSegmentClassifier {
/**
* 生成可被 {@code HlsManifestCleaner.Rule} 执行的条件。
*
- * 时长范围围绕区间内均值取 ±0.1s,仅在时长离群信号成立时给出 ——
- * 否则会写出一条只靠固定时长删片的宽泛规则,这是内置规则策略明确禁止的。
- * minimumSignals 取可编码信号数且不低于 2。
+ *
关键约束:{@code HlsManifestCleaner} 对每个切片独立累加信号,
+ * 达到 minimumSignals 即删除。因此只能编码**有区分度**的条件 ——
+ * 与 playlist 同域的 hostSuffixes 对每个切片都成立,requireDiscontinuity
+ * 对每个断点后的切片都成立,两者凑够 2 个信号会连正片一起删。
+ *
+ *
有区分度的条件只有两类:非本站域名、广告路径特征。至少要有一类,
+ * 否则返回空载荷让通道弃权。
*/
private static RulePayload payloadOf(AdIntervalEvidence evidence, Signals signals) {
+ String playlistHost = evidence.playlistHost();
+ // 只保留非本站域名 —— 同域 host 不是区分条件
List hosts = evidence.inside().stream()
.map(SegmentFact::host)
.filter(host -> !host.isEmpty())
+ .filter(host -> playlistHost.isEmpty() || !hostMatches(host, playlistHost))
.distinct()
.toList();
+ List pathPatterns = signals.pathHint()
+ ? pathPatternsOf(evidence.inside()) : List.of();
+ // 没有任何区分条件时不得生成规则
+ if (hosts.isEmpty() && pathPatterns.isEmpty()) return RulePayload.empty();
+
double min = Double.NaN;
double max = Double.NaN;
if (signals.durationOutlier()) {
@@ -104,12 +117,48 @@ public final class HlsSegmentClassifier {
min = Math.max(0d, mean - 0.1d);
max = mean + 0.1d;
}
- // 作用域必须收窄到当前站点的 playlist 域名,否则规则会污染其他站点
- List scope = evidence.playlistHost().isEmpty()
- ? List.of() : List.of(evidence.playlistHost());
- return RulePayload.ofHlsRule(scope, hosts, min, max,
- signals.discontinuity(), signals.crossDomain(),
- Math.max(2, signals.encodableCount()));
+ // 作用域收窄到当前站点的 playlist 域名,否则规则会污染其他站点
+ List scope = playlistHost.isEmpty() ? List.of() : List.of(playlistHost);
+
+ // requireDiscontinuity 只对广告块首片成立,块内后续切片拿不到这个信号。
+ // 把它计入门限会导致只删掉首片、留下其余广告,因此不编码进规则 ——
+ // 断点已经在归因阶段用于定位区间,规则层不再需要它。
+ // requireCrossDomain 对块内每一片都成立,可以安全编码。
+ boolean requireCrossDomain = signals.crossDomain();
+
+ // 逐项统计真正对块内每一片都成立的信号,minimumSignals 不能超过它,
+ // 否则 HlsAdRule.compile() 会拒绝整条规则。
+ int encoded = 0;
+ if (!hosts.isEmpty()) encoded++;
+ if (!pathPatterns.isEmpty()) encoded++;
+ if (!Double.isNaN(min)) encoded++;
+ if (requireCrossDomain) encoded++;
+
+ // 门限优先取 2 以避免宽泛删片,但不得超过实际可编码的信号数
+ // (超过会让 compile() 拒绝整条规则)。也不取满全部信号 —— 要求
+ // 三个条件同时成立时,广告块里稍有出入的一片就会漏删。
+ int minimumSignals = Math.min(2, encoded);
+
+ return RulePayload.ofHlsRule(scope, hosts, pathPatterns, min, max,
+ false, requireCrossDomain, minimumSignals);
+ }
+
+ /** 命中的广告路径目录段转成正则,用 quote 避免元字符注入。 */
+ private static List pathPatternsOf(List inside) {
+ List patterns = new ArrayList<>();
+ for (String hint : PATH_HINTS) {
+ boolean allMatch = inside.stream()
+ .allMatch(fact -> fact.path().toLowerCase(Locale.US).contains(hint));
+ if (allMatch) patterns.add(Pattern.quote(hint));
+ }
+ return patterns;
+ }
+
+ /** host 是否等于给定域名或为其子域。 */
+ private static boolean hostMatches(String host, String domain) {
+ String lower = host.toLowerCase(Locale.US);
+ String target = domain.toLowerCase(Locale.US);
+ return lower.equals(target) || lower.endsWith("." + target);
}
/** 逐项检测五种信号。 */
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java
index 12d8131fa6..b5db233817 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/RulePayload.java
@@ -67,13 +67,23 @@ public record RulePayload(List playlistHostSuffixes, List hosts,
/** HLS 结构化条件全量。 */
public static RulePayload ofHlsRule(List playlistHostSuffixes, List hosts,
+ List regex,
double durationMin, double durationMax,
boolean requireDiscontinuity, boolean requireCrossDomain,
int minimumSignals) {
- return new RulePayload(playlistHostSuffixes, hosts, List.of(), List.of(), "",
+ return new RulePayload(playlistHostSuffixes, hosts, regex, List.of(), "",
durationMin, durationMax, requireDiscontinuity, requireCrossDomain, minimumSignals);
}
+ /** HLS 结构化条件,无 URL 正则。 */
+ public static RulePayload ofHlsRule(List playlistHostSuffixes, List hosts,
+ double durationMin, double durationMax,
+ boolean requireDiscontinuity, boolean requireCrossDomain,
+ int minimumSignals) {
+ return ofHlsRule(playlistHostSuffixes, hosts, List.of(), durationMin, durationMax,
+ requireDiscontinuity, requireCrossDomain, minimumSignals);
+ }
+
public boolean hasDurationRange() {
return !Double.isNaN(durationMin) && !Double.isNaN(durationMax);
}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/GenRuleE2ETest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/GenRuleE2ETest.java
new file mode 100644
index 0000000000..fe74c98b81
--- /dev/null
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/GenRuleE2ETest.java
@@ -0,0 +1,120 @@
+package com.fongmi.android.tv.ad.feedback;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import com.fongmi.android.tv.bean.HlsAdRule;
+import com.fongmi.android.tv.utils.HlsManifestCleaner;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 端到端:分类器产出的载荷 → HlsAdRule → HlsManifestCleaner,
+ * 验证生成的规则在真实 manifest 上删对了切片、且不误删正片。
+ */
+public class GenRuleE2ETest {
+
+ private static final String BASE = "https://v.example.com/play/index.m3u8";
+ private static final int TOTAL = 30;
+ private static final int AD_FROM = 10;
+ private static final int AD_COUNT = 3;
+
+ private static List seg(int from, int n, String host, String path,
+ double dur, boolean discFirst) {
+ List l = new ArrayList<>();
+ for (int i = 0; i < n; i++) {
+ l.add(new SegmentFact(from + i, host, path + (from + i) + ".ts", dur, discFirst && i == 0));
+ }
+ return l;
+ }
+
+ private static List outsideAround(String host, double dur) {
+ List l = new ArrayList<>(seg(0, AD_FROM, host, "/seg/", dur, false));
+ l.addAll(seg(AD_FROM + AD_COUNT, TOTAL - AD_FROM - AD_COUNT, host, "/seg/", dur, true));
+ return l;
+ }
+
+ private static AdIntervalEvidence ev(List in, List out,
+ boolean bounded, boolean cross) {
+ return new AdIntervalEvidence(80_000, 100_000, StartOrigin.USER_MARKED,
+ "site", "站点", "剧", "线", "1", "v.example.com", "/play/index.m3u8", true,
+ in, out, bounded, cross, List.of(), false, List.of(),
+ AudioIntervalFact.unavailable(), SpeechIntervalFact.unavailable());
+ }
+
+ /** 按 AdRulePlanApplier 的方式组装规则。 */
+ private static HlsManifestCleaner.Rule toRule(RulePayload p) {
+ HlsAdRule r = HlsAdRule.createUserRule("id", "n",
+ p.playlistHostSuffixes(), p.hosts(), p.regex(),
+ p.hasDurationRange() ? p.durationMin() : null,
+ p.hasDurationRange() ? p.durationMax() : null,
+ p.requireDiscontinuity(), p.requireCrossDomain(),
+ p.minimumSignals());
+ return r.compile();
+ }
+
+ /** 30 片,AD_FROM 起 3 片是广告,广告块前后各有断点。 */
+ private static String manifest(String adHost, String adPath, double adDur) {
+ StringBuilder b = new StringBuilder("#EXTM3U\n");
+ for (int i = 0; i < TOTAL; i++) {
+ boolean ad = i >= AD_FROM && i < AD_FROM + AD_COUNT;
+ if (i == AD_FROM || i == AD_FROM + AD_COUNT) b.append("#EXT-X-DISCONTINUITY\n");
+ b.append("#EXTINF:").append(ad ? adDur : 8.0).append(",\n");
+ b.append(ad ? adHost + adPath + i + ".ts" : "https://v.example.com/seg/" + i + ".ts")
+ .append('\n');
+ }
+ return b.append("#EXT-X-ENDLIST\n").toString();
+ }
+
+ @Test
+ public void crossDomainRuleRemovesExactlyTheAdBlock() {
+ AdAttribution plan = HlsSegmentClassifier.classify(
+ ev(seg(AD_FROM, AD_COUNT, "ad-cdn.other.com", "/seg/", 6.4, true),
+ outsideAround("v.example.com", 8.0), true, true));
+ assertNotNull(plan);
+
+ HlsManifestCleaner.Result res = HlsManifestCleaner.clean(BASE,
+ manifest("https://ad-cdn.other.com", "/seg/", 6.4),
+ List.of(toRule(plan.payload())));
+
+ assertEquals("只删广告块", AD_COUNT, res.removedSegments());
+ }
+
+ @Test
+ public void domainChannelRuleRemovesExactlyTheAdBlock() {
+ AdAttribution plan = DomainReputationClassifier.classify(
+ ev(seg(AD_FROM, AD_COUNT, "ad-cdn.other.com", "/seg/", 6.4, false),
+ outsideAround("v.example.com", 8.0), false, true),
+ new DomainReputationClassifier.Input(
+ List.of(), List.of("v.example.com"), List.of(), ""));
+ assertNotNull(plan);
+
+ HlsManifestCleaner.Result res = HlsManifestCleaner.clean(BASE,
+ manifest("https://ad-cdn.other.com", "/seg/", 6.4),
+ List.of(toRule(plan.payload())));
+
+ assertEquals("只删广告块", AD_COUNT, res.removedSegments());
+ }
+
+ /**
+ * 同域广告块:广告与正片同域名、同时长,只有路径特征和断点可用。
+ * 这是最危险的场景 —— 若规则把 playlist 域名当作切片域名条件,
+ * 会连带删掉每一个紧跟断点的正片切片。
+ */
+ @Test
+ public void sameDomainRuleMustNotDeleteLegitSegmentsAfterDiscontinuity() {
+ AdAttribution plan = HlsSegmentClassifier.classify(
+ ev(seg(AD_FROM, AD_COUNT, "v.example.com", "/ads/", 8.0, true),
+ outsideAround("v.example.com", 8.0), true, false));
+ if (plan == null) return; // 通道弃权也是可接受的安全结果
+
+ HlsManifestCleaner.Result res = HlsManifestCleaner.clean(BASE,
+ manifest("https://v.example.com", "/ads/", 8.0),
+ List.of(toRule(plan.payload())));
+
+ assertEquals("只能删广告块,不得误删断点后的正片", AD_COUNT, res.removedSegments());
+ }
+}
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java
index fd4093c95f..8b1c227798 100644
--- a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadCompileContractTest.java
@@ -30,7 +30,7 @@ public class RulePayloadCompileContractTest {
payload.hasDurationRange() ? payload.durationMin() : null,
payload.hasDurationRange() ? payload.durationMax() : null,
payload.requireDiscontinuity(), payload.requireCrossDomain(),
- Math.max(2, payload.minimumSignals()));
+ payload.minimumSignals());
try {
assertNotNull(label, rule.compile());
} catch (RuntimeException e) {
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java
index adfe99a69b..741efc6a7c 100644
--- a/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/RulePayloadTest.java
@@ -89,10 +89,12 @@ public class RulePayloadTest {
// 作用域收窄到本站,避免规则污染其他站点
assertEquals(List.of("v.example.com"), payload.playlistHostSuffixes());
assertEquals(List.of("ad-cdn.other.com"), payload.hosts());
- assertTrue(payload.requireDiscontinuity());
+ // requireDiscontinuity 只对广告块首片成立,编码进规则会导致只删首片、
+ // 留下其余广告,因此刻意不编码 —— 断点只在归因阶段用于定位区间
+ assertFalse(payload.requireDiscontinuity());
assertTrue(payload.requireCrossDomain());
- // 永不生成 minimumSignals=1 的宽泛规则
- assertTrue(payload.minimumSignals() >= 2);
+ // 跨域 + 域名 + 时长离群三个信号,门限取 2
+ assertEquals(2, payload.minimumSignals());
assertTrue(plan.actionable());
}
From b8ca2b361586792e733ad3e062920ea3d38812d3 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sun, 30 Aug 2026 03:40:35 +0800
Subject: [PATCH 15/28] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=AF=84?=
=?UTF-8?q?=E5=AE=A1=E5=8F=91=E7=8E=B0=E7=9A=84=E5=85=AD=E4=B8=AA=E9=97=AE?=
=?UTF-8?q?=E9=A2=98=EF=BC=8C=E5=85=B6=E4=B8=AD=E4=B8=A4=E5=A4=84=E4=BD=BF?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=88=90=E4=B8=BA=E6=AD=BB=E4=BB=A3=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. 三端 submitAdFeedback 直接调 player().getPosition() 无 null 保护。
player() 在 service 断连时返回 null,PlayerManager.getPosition() 内部
也无守卫。改为经 AdFeedbackHostAdapter.safePositionMs() 取值,取不到
返回 -1;且先取位置再清 mAdMarkStartMs —— 原顺序会在异常时丢失标记。
2. AdFeedbackController.generation 非 volatile 但跨线程读写,加 volatile。
3. 换源换集不再作废在途归因。TmdbDetail 原来递增 mAdFeedbackGeneration
作废旧回调,接线时替换成 invalidateEvidence() 却丢了作废语义:后台
fetchEvidence() 返回后仍会用上一集的证据弹窗,用户按「保存规则」写入的
是上一集的域名与切片条件。新增 AdFeedbackController.invalidate(),三端
统一经 resetAdFeedback() 调用。mobile/leanback 原本连 invalidateEvidence()
都没接,换集后短按会拿上一集的切片结构推断起点。
4. recordPlaybackHost() 全仓无生产调用,导致 siteBaselineHosts() 恒为空、
DomainReputationClassifier 因「无基线不断言」而永久弃权 —— 整条域名通道
是死代码。三端在播放地址确定后接线。
5. AdFeedbackDialog 未在 onDestroy 关闭。usableActivity() 已挡住销毁后弹窗,
但 AlertDialog 强引用字段仍会经 dialog→Context 泄漏 Activity。三端
onDestroy 加 close(),与 AdSkipPromptPresenter 的既有做法一致。
6. AdSkipCoordinator.onUserInterval 的 seek 失败恢复复查无字段污染
(activeKey/activeCandidate/undoContext 在该方法内从未被写),保持现状。
TmdbDetailActivityLayoutTest 补四条断言锁定上述接线,防止回归。
Verification: testMobileArm64_v8aDebugUnitTest ad.feedback + ui.activity: 471 tests 0 failures; full suite 4013 tests with the same 5 pre-existing failures as baseline; compileMobileArm64_v8aDebug + compileLeanbackArm64_v8aDebug OK
Task-Guard: AF5b-review-fixes
---
.../android/tv/ui/activity/VideoActivity.java | 23 ++++++++++++-
.../tv/ad/feedback/AdFeedbackController.java | 23 +++++++++++--
.../tv/ad/feedback/AdFeedbackHostAdapter.java | 32 ++++++++++++++++---
.../tv/ui/activity/TmdbDetailActivity.java | 27 +++++++++++++---
.../android/tv/ui/activity/VideoActivity.java | 23 ++++++++++++-
.../TmdbDetailActivityLayoutTest.java | 17 +++++++++-
6 files changed, 130 insertions(+), 15 deletions(-)
diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
index 4fe1295f3a..b8d753bc35 100644
--- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
+++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
@@ -2570,6 +2570,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET;
}
private void getPlayer(Flag flag, Episode episode) {
+ resetAdFeedback();
mBinding.widget.title.setText(getPlaybackControlTitle(episode));
playerStartTime = System.currentTimeMillis();
beginPlayHealth();
@@ -4020,15 +4021,33 @@ private long mInitialPlaybackPosition = C.TIME_UNSET;
return mAdFeedback;
}
+ /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */
+ private void resetAdFeedback() {
+ mAdMarkStartMs = -1;
+ if (mAdFeedback != null) mAdFeedback.invalidate();
+ if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence();
+ }
+
+ /** 起播后记录本站域名,供域名信誉通道建立基线。 */
+ private void recordAdFeedbackHost() {
+ if (mAdFeedbackHost != null) mAdFeedbackHost.recordPlaybackHost();
+ }
+
/** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */
private void submitAdFeedback() {
AdFeedbackController controller = adFeedback();
AdBlockStatsStore.recordFeedback(getKey());
AdFeedbackSession session;
if (mAdMarkStartMs >= 0) {
+ // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点
+ long end = mAdFeedbackHost.safePositionMs();
+ if (end < 0) {
+ Notify.show(R.string.ad_interval_invalid);
+ return;
+ }
long start = mAdMarkStartMs;
mAdMarkStartMs = -1;
- session = controller.onMarkedInterval(start, player().getPosition());
+ session = controller.onMarkedInterval(start, end);
} else {
session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence());
}
@@ -4997,6 +5016,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET;
requestIntroSkipPlan();
if (!pendingResumeSeekApplied) applyAutoIntroSkip();
setAdFeedbackVisible(); // 播放地址确定后按格式刷新"有广告"按钮
+ recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线
break;
case Player.STATE_ENDED:
checkEnded(true);
@@ -7231,6 +7251,7 @@ private long mInitialPlaybackPosition = C.TIME_UNSET;
}
@Override
protected void onDestroy() {
+ if (mAdFeedbackDialog != null) mAdFeedbackDialog.close();
cancelAiSeasonAnalysis(false);
mLyricsSearchSeq++;
mLyricsRefreshSeq++;
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java
index 98b160adcb..add0e942ed 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackController.java
@@ -74,8 +74,11 @@ public final class AdFeedbackController {
private final Host host;
private final AdFeedbackDiagnostics diagnostics;
- /** 防止过期回调覆盖新一次反馈的结果。 */
- private int generation;
+ /**
+ * 防止过期回调覆盖新一次反馈的结果。写在主线程({@link #submit})、
+ * 读在后台线程投递回主线程的 lambda 里,故必须 volatile。
+ */
+ private volatile int generation;
public AdFeedbackController(Host host) {
this(host, new AdFeedbackDiagnostics());
@@ -91,6 +94,15 @@ public final class AdFeedbackController {
return diagnostics;
}
+ /**
+ * 作废所有在途归因。换源、换集或播放器重建时必须调用 —— 否则后台
+ * {@code fetchEvidence()} 返回后仍会用上一集的证据弹窗,用户按「保存规则」
+ * 写入的是上一集的域名与切片条件。
+ */
+ public void invalidate() {
+ generation++;
+ }
+
/**
* 短按:以当前位置为终点,回溯推断起点。
*
@@ -104,7 +116,12 @@ public final class AdFeedbackController {
return submit(start.startMs(), endMs, start.origin());
}
- /** 长按标记模式:用户显式框选的区间。 */
+ /**
+ * 长按标记模式:用户显式框选的区间。
+ *
+ * @param startMs 标记模式下打的起点
+ * @param endMs 终点,由调用方取当前播放位置;取不到时传负数即被拒绝
+ */
public AdFeedbackSession onMarkedInterval(long startMs, long endMs) {
return submit(startMs, endMs, StartOrigin.USER_MARKED);
}
diff --git a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java
index 7fd1723534..abb4c509b9 100644
--- a/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java
+++ b/app/src/main/java/com/fongmi/android/tv/ad/feedback/AdFeedbackHostAdapter.java
@@ -108,16 +108,38 @@ public final class AdFeedbackHostAdapter implements AdFeedbackController.Host {
cachedEvidence = null;
}
- /** 记录一次成功播放的域名,喂给站点基线。 */
+ /**
+ * 当前播放位置,取不到时返回 -1 而非 0 —— 0 会被误当成合法的
+ * 「片头位置」,而 -1 会让 {@code AdFeedbackController.submit} 直接拒绝。
+ */
+ public long safePositionMs() {
+ try {
+ long position = playback.positionMs();
+ return position > 0 ? position : -1L;
+ } catch (RuntimeException e) {
+ return -1L;
+ }
+ }
+
+ /**
+ * 记录一次成功播放的域名,喂给站点基线。
+ *
+ * 必须在播放起播后调用:{@link DomainReputationClassifier} 无基线数据时
+ * 一律弃权(避免首次播放即误判),不接线会让整条域名通道成为死代码。
+ */
public void recordPlaybackHost() {
- String host = AdEvidenceCollector.hostOf(playback.playUrl());
- if (host.isEmpty()) return;
- deps.baseline().record(metadata.siteKey(), host);
+ try {
+ String host = AdEvidenceCollector.hostOf(playback.playUrl());
+ if (host.isEmpty()) return;
+ deps.baseline().record(metadata.siteKey(), host);
+ } catch (RuntimeException ignored) {
+ // 基线只是归因的辅助输入,记录失败不应影响播放
+ }
}
@Override
public long positionMs() {
- return playback.positionMs();
+ return safePositionMs();
}
@Override
diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java
index 65c1be4d59..62e8a08a21 100644
--- a/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java
+++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java
@@ -6894,9 +6894,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
}
private void stopInlinePlayerForReload() {
- // 换源换集:作废标记模式并清掉上一集的切片证据缓存
- mAdMarkStartMs = -1;
- if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence();
+ // 换源换集:作废在途归因,避免用上一集的证据弹窗并写错规则
+ resetAdFeedback();
subtitlePlaybackSession.stop(this);
inlineStartPosition = C.TIME_UNSET;
inlineStartPositionApplied = false;
@@ -8322,15 +8321,33 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
return mAdFeedback;
}
+ /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */
+ private void resetAdFeedback() {
+ mAdMarkStartMs = -1;
+ if (mAdFeedback != null) mAdFeedback.invalidate();
+ if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence();
+ }
+
+ /** 起播后记录本站域名,供域名信誉通道建立基线。 */
+ private void recordAdFeedbackHost() {
+ if (mAdFeedbackHost != null) mAdFeedbackHost.recordPlaybackHost();
+ }
+
/** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */
private void submitInlineAdFeedback() {
AdFeedbackController controller = adFeedback();
AdBlockStatsStore.recordFeedback(getKeyText());
AdFeedbackSession session;
if (mAdMarkStartMs >= 0) {
+ // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点
+ long end = mAdFeedbackHost.safePositionMs();
+ if (end < 0) {
+ Notify.show(R.string.ad_interval_invalid);
+ return;
+ }
long start = mAdMarkStartMs;
mAdMarkStartMs = -1;
- session = controller.onMarkedInterval(start, player().getPosition());
+ session = controller.onMarkedInterval(start, end);
} else {
session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence());
}
@@ -10270,6 +10287,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
player().reset();
applyInlineStartPosition();
updateInlineButtons(player().isPlaying());
+ recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线
applyInlineShortDramaMode();
requestIntroSkipPlan();
applyAutoIntroSkip();
@@ -10430,6 +10448,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.
@Override
protected void onDestroy() {
+ if (mAdFeedbackDialog != null) mAdFeedbackDialog.close();
loadGeneration++;
cancelAiSeasonAnalysis(false);
detailTasks.close();
diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
index 48f96ee2ce..1931b78dd9 100644
--- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
+++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java
@@ -2290,6 +2290,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco
}
private void getPlayer(Flag flag, Episode episode) {
+ resetAdFeedback();
mBinding.control.title.setText(getPlaybackControlTitle(episode));
playerStartTime = System.currentTimeMillis();
beginPlayHealth();
@@ -6721,6 +6722,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco
requestIntroSkipPlan();
if (!pendingResumeSeekApplied) applyAutoIntroSkip();
setAdFeedbackVisible(); // 播放地址确定后按格式刷新"有广告"按钮
+ recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线
break;
case Player.STATE_ENDED:
checkEnded(true);
@@ -8656,6 +8658,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco
}
@Override
protected void onDestroy() {
+ if (mAdFeedbackDialog != null) mAdFeedbackDialog.close();
cancelAiSeasonAnalysis(false);
dismissKaraokeResultDialogForRecreation();
mLyricsSearchSeq++;
@@ -8749,15 +8752,33 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco
return mAdFeedback;
}
+ /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */
+ private void resetAdFeedback() {
+ mAdMarkStartMs = -1;
+ if (mAdFeedback != null) mAdFeedback.invalidate();
+ if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence();
+ }
+
+ /** 起播后记录本站域名,供域名信誉通道建立基线。 */
+ private void recordAdFeedbackHost() {
+ if (mAdFeedbackHost != null) mAdFeedbackHost.recordPlaybackHost();
+ }
+
/** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */
private void submitAdFeedback() {
AdFeedbackController controller = adFeedback();
AdBlockStatsStore.recordFeedback(getKey());
AdFeedbackSession session;
if (mAdMarkStartMs >= 0) {
+ // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点
+ long end = mAdFeedbackHost.safePositionMs();
+ if (end < 0) {
+ Notify.show(R.string.ad_interval_invalid);
+ return;
+ }
long start = mAdMarkStartMs;
mAdMarkStartMs = -1;
- session = controller.onMarkedInterval(start, player().getPosition());
+ session = controller.onMarkedInterval(start, end);
} else {
session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence());
}
diff --git a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java
index 464a4eb478..61ec91b5e2 100644
--- a/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java
+++ b/app/src/testMobile/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivityLayoutTest.java
@@ -450,9 +450,24 @@ public class TmdbDetailActivityLayoutTest {
source.contains("Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection()"));
assertTrue("detail inline playback must route ad feedback through the shared controller",
source.contains("private void submitInlineAdFeedback()")
- && source.contains("controller.onMarkedInterval(start, player().getPosition())")
+ && source.contains("controller.onMarkedInterval(start, end)")
&& source.contains("controller.onQuickReport(mAdFeedbackHost.cachedEvidence())")
&& source.contains("new AdFeedbackController(mAdFeedbackHost)"));
+ assertTrue("marked interval must read the position before clearing the mark so a released player keeps it",
+ source.contains("long end = mAdFeedbackHost.safePositionMs();")
+ && source.indexOf("long end = mAdFeedbackHost.safePositionMs();")
+ < source.indexOf("long start = mAdMarkStartMs;"));
+ assertTrue("source or episode switch must invalidate in-flight attribution",
+ source.contains("private void resetAdFeedback()")
+ && source.contains("mAdFeedback.invalidate();")
+ && source.contains("mAdFeedbackHost.invalidateEvidence();")
+ && source.contains("resetAdFeedback();"));
+ assertTrue("playback host must feed the site baseline or the domain channel stays dead",
+ source.contains("private void recordAdFeedbackHost()")
+ && source.contains("mAdFeedbackHost.recordPlaybackHost();")
+ && source.contains("recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线"));
+ assertTrue("the feedback dialog must be closed on destroy to avoid leaking the activity",
+ source.contains("if (mAdFeedbackDialog != null) mAdFeedbackDialog.close();"));
assertTrue("long press must arm the interval marking mode",
source.contains("private boolean onInlineAdFeedbackLongPress()")
&& source.contains("binding.playerAdFeedback.setOnLongClickListener(view -> onInlineAdFeedbackLongPress());")
From e881bc9115354cddddfc9113f2fd9d322c37c469 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sun, 30 Aug 2026 04:15:28 +0800
Subject: [PATCH 16/28] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E9=94=81?=
=?UTF-8?q?=E5=AE=9A=E7=89=87=E5=A4=B4=E9=92=B3=E4=BD=8D=E3=80=81=E4=BD=8D?=
=?UTF-8?q?=E7=BD=AE=E4=B8=8D=E5=8F=AF=E7=94=A8=E4=B8=8E=E6=8D=A2=E9=9B=86?=
=?UTF-8?q?=E4=BD=9C=E5=BA=9F=E4=B8=89=E4=B8=AA=E8=BE=B9=E7=95=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 片头广告是最常见场景,位置远小于 90 秒回溯窗口,起点必须钳到 0;
之前只测过 200 秒位置的常规路径。
- safePositionMs() 返回 -1 时必须被拒,不能当成合法的片头位置 0。
- invalidate() 后过期归因回来不得再刷新界面 —— 这是上一个提交修的
换集串台问题,补测试防回归。
Verification: testMobileArm64_v8aDebugUnitTest AdFeedbackControllerTest: 16 tests 0 failures
Task-Guard: AF5c-preroll-test
---
.../ad/feedback/AdFeedbackControllerTest.java | 44 +++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java
index 86785af554..34d56a1899 100644
--- a/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java
+++ b/app/src/test/java/com/fongmi/android/tv/ad/feedback/AdFeedbackControllerTest.java
@@ -122,6 +122,50 @@ public class AdFeedbackControllerTest {
.count(AdFeedbackDiagnostics.Code.START_INFERRED_FROM_FALLBACK));
}
+ @Test
+ public void prerollReportEarlyInPlaybackClampsStartToZero() {
+ // 片头广告是最常见场景:位置远小于回溯窗口,起点必须钳到 0 而非变负
+ FakeHost host = new FakeHost();
+ host.position = 12_000;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ AdFeedbackSession session = controller.onQuickReport(null);
+
+ assertNotNull("片头位置也必须能反馈", session);
+ assertEquals(0L, session.startMs());
+ assertEquals(12_000L, session.endMs());
+ assertEquals(List.of("0-12000"), host.skipCalls);
+ }
+
+ @Test
+ public void unavailablePositionIsRejectedNotTreatedAsZero() {
+ // 播放器已释放时 safePositionMs 返回 -1,不能被当成合法的片头位置
+ FakeHost host = new FakeHost();
+ host.position = -1;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ assertNull(controller.onQuickReport(null));
+ assertTrue(host.skipCalls.isEmpty());
+ assertTrue(host.shownSessions.isEmpty());
+ }
+
+ @Test
+ public void invalidateDropsInFlightAnalysisFromPreviousEpisode() {
+ // 换集后旧归因回来时不得覆盖 UI —— 否则用户会看到上一集的结论
+ FakeHost host = new FakeHost();
+ host.playlist = adBlockPlaylist();
+ host.deferBackground = true;
+ AdFeedbackController controller = new AdFeedbackController(host);
+
+ controller.onMarkedInterval(30_000, 60_000);
+ int shownBefore = host.shownSessions.size();
+ controller.invalidate();
+ host.deferBackground = false;
+ host.backgroundTasks.get(0).run();
+
+ assertEquals("过期归因不得再刷新界面", shownBefore, host.shownSessions.size());
+ }
+
@Test
public void rejectsInvalidIntervals() {
FakeHost host = new FakeHost();
From 756954e2c5e26eaf9d337b3b137dc8de33c74f21 Mon Sep 17 00:00:00 2001
From: Silent1566 <245409147+Silent1566@users.noreply.github.com>
Date: Sun, 30 Aug 2026 04:17:48 +0800
Subject: [PATCH 17/28] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E5=8C=BA?=
=?UTF-8?q?=E9=97=B4=E5=8F=8D=E9=A6=88=E4=B8=8E=E5=A4=9A=E9=80=9A=E9=81=93?=
=?UTF-8?q?=E5=BD=92=E5=9B=A0=E8=AE=BE=E8=AE=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
本次功能的设计依据,含背景、数据契约、各通道判据、仲裁规则、
分阶段计划与架构决策摘要。实现过程中修正的两处设计缺口也已回写:
HlsAdRule 不支持位置信号、域名黑名单只在 WebView 层生效。
---
docs/ad-feedback-timerange-design.md | 527 +++++++++++++++++++++++++++
1 file changed, 527 insertions(+)
create mode 100644 docs/ad-feedback-timerange-design.md
diff --git a/docs/ad-feedback-timerange-design.md b/docs/ad-feedback-timerange-design.md
new file mode 100644
index 0000000000..fe9294f6bb
--- /dev/null
+++ b/docs/ad-feedback-timerange-design.md
@@ -0,0 +1,527 @@
+# 「有广告」按钮升级:区间反馈与多通道综合归因设计
+
+## 1. 文档状态
+
+- 状态:提议(Proposed),待用户批准分阶段实施
+- 版本:v0.1
+- 日期:2026-08-29
+- 适用范围:播放器「有广告」按钮、广告归因分类器、候选规则落地、本次播放即时跳过
+- 上游文档:`智能去广-设计文档.md`(V2 总设计)、`docs/AI_AD_DETECTION_DESIGN.md`、`docs/audio-fingerprint-phase0-implementation.md`、`docs/interface-ad-rule-learning-design.md`
+- 已确认决策:候选落地采用「本次立即跳过 + 同时生成待审候选」;通道分阶段接入;规则纯本地,不上传、不众包
+
+## 2. 背景
+
+### 2.1 现状
+
+「有广告」按钮当前是**单通道**的:点击后只做一件事 —— 把站点元数据 + m3u8 切片摘要送给用户自配的 AI 端点,让大模型猜 `hosts` / `regex` / `exclude`,然后弹 `AdRulePreviewDialog` 让用户确认写入 `UserAdRule`。
+
+三处入口(`app/src/leanback/.../VideoActivity.java:3982`、`app/src/mobile/.../VideoActivity.java:8661`、`app/src/main/.../TmdbDetailActivity.java:8286`)是三份几乎逐字重复的实现。
+
+显示门槛是四个条件的与:
+```java
+Setting.isAiConfigReady() && Setting.isAdblock()
+ && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat()
+```
+其中 `isAiAdDetection()` 默认关闭(`Setting.java:868`),`isAdFeedbackSupportedFormat()` 要求 `MediaSourceFactory.isHlsUrl(url)`。**即按钮默认不可见,且强绑 AI。**
+
+### 2.2 三个具体缺陷
+
+1. **反馈粒度只有「这一集有广告」**。用户明明知道广告在 02:15–02:45,这个信息被完整丢弃。分类器只能拿到整条 playlist 的统计特征,无法定位。
+2. **项目已有的其他检测能力完全没有参与**。仓库里已落地 HLS 结构化净化(`HlsManifestCleaner`,318 行)、音频频谱指纹(`ad/audio/` 41 个类,8000+ 行)、语音关键词(`SpeechAdSignalProvider`)、域名黑名单(`RuleConfig`)、接口规则学习(`InterfaceAdRuleAnalyzer`)—— 但「有广告」按钮一个都不用,只调 AI。
+3. **用户反馈完了什么都没得到**。当前流程最好的结果是「下次播放可能不再有广告」。本次播放的广告仍然要看完。
+
+### 2.3 一个必须先修的既有问题
+
+`HlsAdblockPipeline.apply()` 在两个调用点(`server/process/M3u8.java:91`、`androidx/media3/mpvplayer/MpvHlsProxy.java:799`)都传 `legacyFallback=true`。结构化规则未命中时会回落到预编译 AAR 里的 `HlsAdsParser.process()` —— 那是一套**没有规则、没有安全阈值、按众数块大小猜少数派**的启发式引擎。
+
+由于内置的 3 条 HLS 规则全部 `enabledByDefault: false`(`assets/rules/hls_rules.json`),**当前线上实际生效的去广主力是这套启发式,而不是文档重点描述的结构化引擎。** 本设计的归因结论必须能区分「广告是被启发式删掉的」和「被结构化规则删掉的」,否则证据链是错的。
+
+## 3. 目标与非目标
+
+### 3.1 目标
+
+1. 用户可提交广告的**开始/结束时间**,而不只是「有广告」这一个 bit。
+2. APP 用**已有的全部检测能力**对该区间做归因分类,输出「这段广告属于哪一类、最适合用哪种机制拦掉」。
+3. 分类结论按可靠性排序,选出**成本最低且证据最强**的那一种去广方式,而不是无脑走 AI。
+4. 用户提交后**当次播放立即跳过该区间**,不等分类完成。
+5. 分类产出的规则进入待审候选,用户确认后长期生效。
+6. 不引入服务端;不上传播放行为;AI 仍然可选且可完全关闭。
+7. 按钮在**关闭 AI 时也可用**(降级为纯本地归因)。
+
+### 3.2 非目标
+
+1. 不做视觉识别 / OCR / 帧分析。当前仓库无此能力,引入代价与收益不成比例。
+2. 不建服务端,不做跨用户众包聚合,不上报任何播放数据(承接现有「不承诺」清单)。
+3. 不为直播实现区间反馈(直播无稳定时间轴,`HlsManifestCleaner` 对 live 也只允许删连续头部分片)。
+4. 不承诺 IJK / MPV 的音频指纹通道(现有限制照旧,仅 Exo 支持 PCM 捕获)。
+5. 不自动启用高风险规则(宽泛固定时长、全文正则)。
+6. 不改动 `HlsAdsParser`(在预编译 AAR 内,属于 upstream 依赖,改它要走 `E*` 任务流程)。
+
+## 4. 用户交互设计
+
+### 4.1 区间采集:三种输入路径
+
+单个按钮承担不了区间输入。改为**长按/短按分流 + 一个轻量标记条**。
+
+| 操作 | 行为 | 适用 |
+|---|---|---|
+| 短按「有广告」 | 以当前位置为**结束时间**,向前回溯猜测开始时间(见 4.2),直接进入确认 | 广告刚放完,用户反应过来了。最常见 |
+| 长按「有广告」 | 进入**标记模式**:第一次确认打起点,第二次确认打终点 | 广告正在放,用户想精确框选 |
+| 标记模式中再次长按 | 取消标记 | 误触 |
+
+TV(leanback)端焦点资源紧张,长按走 `OnLongClickListener`;移动端同时在标记模式下于进度条上叠加区间高亮,允许拖动微调两个端点。
+
+### 4.2 短按的起点推断
+
+短按只有一个时间点。起点按优先级推断,第一个成功的即采用:
+
+1. **最近一次 `DISCONTINUITY` 边界**。从 `M3u8Evidence.discontinuities` 找当前位置之前最近的断点,落在其上。这是最可靠的信号 —— 硬插广告几乎总带断点。
+2. **最近一次跨域切换点**。`M3u8Evidence.domainSwitches` 中当前位置前最近的 `true`。
+3. **最近一次音频/语音候选的 `startTimeMs`**。若 `AdSkipCoordinator` 在 `SUPPRESSED` 或 `UNDO_WINDOW` 状态持有过候选,直接复用。
+4. **固定回溯窗口**。默认 90 秒,钳制到 `[0, 当前位置)`。
+
+推断出的起点必须在确认对话框里**明示并可调**,不能静默使用。文案示例:「广告区间 02:15 – 02:45(起点由切片断点推断)」。
+
+### 4.3 确认对话框
+
+复用并扩展 `AdRulePreviewDialog`(leanback / mobile 各一份)。分两个阶段展示:
+
+```
+第一屏(提交即出,< 100ms)
+ 广告区间 02:15 – 02:45 (30.0s) [调整] [确认]
+ ☑ 本次播放立即跳过这段
+
+第二屏(归因完成后原地替换内容,通常 1–3s,含 AI 时可能 10s+)
+ 归因结果:切片跨域 + 固定时长(置信度 0.82)
+ 建议方式:HLS 结构化规则 [风险:中]
+ hostSuffixes: ad-cdn.example.com
+ durationRange: 6.40 – 6.47
+ requireDiscontinuity: true
+ minimumSignals: 2
+ 证据:区间内 5 个切片全部来自 ad-cdn.example.com,
+ 主 playlist 域名为 v.example.com;区间前后各有一个
+ #EXT-X-DISCONTINUITY;切片时长标准差 0.02s
+ [保存为规则] [仅本次跳过] [丢弃]
+```
+
+「本次播放立即跳过」默认勾选,勾选状态即视为对**本片段本次执行**的授权,与「保存为规则」的长期授权分离。这是决策 A 的落点。
+
+### 4.4 按钮可见性放宽
+
+```java
+// 新条件:去广总开关 + 可定位的时间轴,AI 不再是硬门槛
+private boolean isAdFeedbackEnabled() {
+ return Setting.isAdblock()
+ && !isLive()
+ && getDuration() > 0
+ && isSeekable();
+}
+```
+
+AI 从「按钮显示前置条件」降级为「归因通道之一」。关闭 AI 时按钮仍可用,只是少一个通道。HLS 格式也不再是硬门槛 —— 非 HLS 时 HLS 通道自然弃权,音频/语音通道仍可工作。
+
+## 5. 总体架构
+
+```mermaid
+flowchart TD
+ U["用户提交 [startMs, endMs]"] --> R["AdFeedbackRequest 构造"]
+ R --> IMM["立即执行:本次跳过 (AdSkipCoordinator)"]
+ R --> COL["证据采集 AdEvidenceCollector"]
+ COL --> E["AdIntervalEvidence 快照"]
+ E --> C1["HlsSegmentClassifier"]
+ E --> C2["DomainReputationClassifier"]
+ E --> C3["ExistingRuleClassifier"]
+ E --> C4["AudioFingerprintClassifier"]
+ E --> C5["SpeechKeywordClassifier"]
+ E --> C6["AiClassifier (可选)"]
+ C1 --> A["AdAttributionArbiter 仲裁"]
+ C2 --> A
+ C3 --> A
+ C4 --> A
+ C5 --> A
+ C6 --> A
+ A --> P["AdRemediationPlan 最优方案"]
+ P --> UI["确认对话框第二屏"]
+ UI -->|用户确认| S["写入 UserAdRule / HlsAdRule / AudioFingerprintRule"]
+ S --> INV["RuleConfig.invalidate() + HlsRuleConfig.invalidate()"]
+```
+
+### 5.1 模块职责
+
+| 模块 | 职责 | 不负责 |
+|---|---|---|
+| `AdFeedbackController` | 三端共用的编排器:接收区间、触发立即跳过、调度分类、驱动对话框 | 具体分类逻辑 |
+| `AdEvidenceCollector` | 采集区间证据快照,一次性、幂等、可缓存 | 判断是不是广告 |
+| `AdIntervalClassifier`(接口) | 单通道分类,输出 0..n 个 `AdAttribution` 或弃权 | 跨通道比较 |
+| `AdAttributionArbiter` | 合并、去重、置信度融合、成本排序,选出最优方案 | 写规则 |
+| `AdRemediationPlanner` | 把仲裁结论翻译成具体规则对象(`HlsAdRule` / `UserAdRule` / `AudioFingerprintRule`) | 落盘 |
+| `AdFeedbackDiagnostics` | 固定枚举计数,不记 URL / 规则正文 | 上报 |
+
+### 5.2 三端去重
+
+现有三份重复实现是这次改造的最大阻力。新逻辑**全部放进 `app/src/main/` 的 `AdFeedbackController`**,三端 Activity 只保留:
+
+```java
+// 每端约 15 行
+mAdFeedback = new AdFeedbackController(new AdFeedbackController.Host() {
+ public long positionMs() { return mPlayer.getPosition(); }
+ public long durationMs() { return mPlayer.getDuration(); }
+ public String playUrl() { return mPlayer.getUrl(); }
+ public Map